Compare commits
132 Commits
v0.5.0
..
b1023f3d72
| Author | SHA1 | Date | |
|---|---|---|---|
| b1023f3d72 | |||
| a9fcf90e86 | |||
| eceae2781a | |||
| b4bb531200 | |||
| 469a91b9bb | |||
| e4ff71a86b | |||
| 3f736ff440 | |||
| 80dc263915 | |||
| 2142c2e3bb | |||
| 4bdb1e64b0 | |||
| 9eebd42887 | |||
| 9a07f75bdf | |||
| 2ac72fb3f4 | |||
| 3cc38325cc | |||
| b70f0d6ade | |||
| ebc5db5a00 | |||
| b0b7404477 | |||
| c32c1bc65b | |||
| 20aa2828c2 | |||
| af29846544 | |||
| 65fadfc93a | |||
| 2a6df3ffe2 | |||
| 30245bda21 | |||
| 5e132977f4 | |||
| 669cbde9d0 | |||
| 696562daec | |||
| bfd0ed1b98 | |||
| 8d443bf670 | |||
| 1371137fef | |||
| 809acd487e | |||
| acad60c889 | |||
| b1a4201a7d | |||
| 7117c91eab | |||
| 91c155175f | |||
| 0b9b210fb2 | |||
| 9dda1a6917 | |||
| bd1284b661 | |||
| 76c6783317 | |||
| 4031cfadf1 | |||
| efb59b6cff | |||
| b2183ea30e | |||
| b90c2647be | |||
| b9f278b0b0 | |||
| ac9cb40d69 | |||
| 9724dd1751 | |||
| 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,87 @@
|
|||||||
|
# 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.
|
||||||
|
- `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is
|
||||||
|
removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it
|
||||||
|
accepted or had accepted with `session.bound(bindType, declaredVersion)`, which returns `err` for a
|
||||||
|
bind type or version it cannot record. An assignment to either field does not compile in
|
||||||
|
TypeScript, throws a `TypeError` in strict-mode code (every ES module, and any file under
|
||||||
|
`'use strict'`), and is ignored otherwise.
|
||||||
|
|
||||||
|
## 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).
|
||||||
+14
-1
@@ -13,7 +13,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
|
|||||||
a `session` event. It no longer calls back once per connection.
|
a `session` event. It no longer calls back once per connection.
|
||||||
- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the
|
- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the
|
||||||
id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
|
id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
|
||||||
Assigning to it throws a `TypeError`, since modules are strict mode.
|
Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under
|
||||||
|
`'use strict'`), and is ignored otherwise.
|
||||||
- **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with
|
- **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with
|
||||||
`pduObjs`, `undefined` where the SMSC took the segment without naming an id.
|
`pduObjs`, `undefined` where the SMSC took the segment without naming an id.
|
||||||
- **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and
|
- **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and
|
||||||
@@ -30,6 +31,15 @@ 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
|
`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
|
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.
|
`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.
|
||||||
|
- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound,
|
||||||
|
`session.boundAs !== undefined` says a bind happened, and `disconnected`/`reconnected` say whether
|
||||||
|
the link is up now. A session you construct yourself records a bind with `session.bound()`.
|
||||||
- **The `error` event is `sessionError`**, and `serverError` on the server handle.
|
- **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
|
- **`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).
|
a `larvitutils` one, and is silent by default: [README](README.md#logging).
|
||||||
@@ -70,6 +80,9 @@ have for these:
|
|||||||
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into
|
- 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.
|
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.
|
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
|
- 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
|
`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.
|
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) ·
|
[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
|
||||||
[Server in depth](#server-in-depth) · [Logging](#logging) ·
|
[Server in depth](#server-in-depth) · [Logging](#logging) ·
|
||||||
[PDUs and the low-level API](#pdus-and-the-low-level-api) ·
|
[PDUs and the low-level API](#pdus-and-the-low-level-api) ·
|
||||||
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Development](#development)
|
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Goals](#goals) ·
|
||||||
|
[Audience](#audience) · [Development](#development)
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -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
|
Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound
|
||||||
`dlr` events, not here. A multipart message arrives reassembled and already answered segment by
|
past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A
|
||||||
segment, so `sendResp()` there only says you are done with it: [Receiving in depth](#receiving-in-depth).
|
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
|
## 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).
|
`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
|
- 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.
|
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
|
## 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. |
|
| `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. |
|
||||||
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
|
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
|
||||||
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
|
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
|
||||||
|
| `connectTimeout` | `10000` | Give up on **each connect attempt** the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. It bounds the socket and the handshake — never the `client()` call, and never the wait for the bind response, which is `responseTimeout`. `false` waits the operating system out instead, around 130 s on Linux; `0` is refused. |
|
||||||
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
|
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
|
||||||
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
|
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
|
||||||
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
|
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
|
||||||
@@ -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. |
|
| `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. |
|
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
|
||||||
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
|
| `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. |
|
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
|
||||||
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
|
| `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
|
**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.**
|
**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
|
- 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
|
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.
|
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,
|
- `err` is set when the SMSC refuses a segment, naming the status, or leaves one unanswered. Every
|
||||||
so `pduObjs` and `smsIds` then hold what was accepted: enough to reconcile a later receipt, not
|
segment goes out together, so `pduObjs` and `smsIds` then hold only the accepted segments, in send
|
||||||
enough to resend the rest. Treat a partial failure as a failed message.
|
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
|
- `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
|
and lost only the response, so a message with `unanswered` above zero cannot be resent without
|
||||||
risking a duplicate.
|
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.
|
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.
|
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
|
**What gets checked.** The library checks what it composes: an address you gave as `from` or `to`,
|
||||||
body under a `data_coding` you named. What you formed yourself, a `Buffer` body or a stamp you
|
an alphabet or a time you named, a string body under a `data_coding` you named. What you formed
|
||||||
formatted, passes through as written. The same rule holds for `session.send()`.
|
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
|
## 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.
|
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.
|
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
|
A message left unanswered for five minutes is no longer waited for.
|
||||||
dropped with a warning on the log and waited for no longer. Neither bound is an option.
|
|
||||||
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
|
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
|
||||||
`responseTimeout` for its own response.
|
`responseTimeout` for its own response.
|
||||||
|
|
||||||
@@ -413,8 +421,18 @@ const { err, pduObj } = await session.send({
|
|||||||
- `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which
|
- `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which
|
||||||
optional parameters may be sent to it. The library's own senders check it before attaching a TLV;
|
optional parameters may be sent to it. The library's own senders check it before attaching a TLV;
|
||||||
a `send()` you build is passed through as written, so check it yourself.
|
a `send()` you build is passed through as written, so check it yourself.
|
||||||
- `peerInterfaceVersion`: the version the peer declared, `0x00` if none.
|
- `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind.
|
||||||
- `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction).
|
- `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction).
|
||||||
|
- `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the
|
||||||
|
link binds again.
|
||||||
|
- `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever
|
||||||
|
end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`;
|
||||||
|
`declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err`
|
||||||
|
and records nothing.
|
||||||
|
- An ESME wired by hand sends its own `bind_<bindType>` through `session.send()`, after it is
|
||||||
|
constructed and again in `reconnect.onConnected`, and records each accepted one with
|
||||||
|
`session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one
|
||||||
|
it sent and `pduObj` the `bind_resp` that `send()` resolved with.
|
||||||
|
|
||||||
## Receiving in depth
|
## Receiving in depth
|
||||||
|
|
||||||
@@ -424,6 +442,12 @@ const { err, pduObj } = await session.send({
|
|||||||
each is two messages.
|
each is two messages.
|
||||||
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
|
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
|
||||||
[Server in depth](#server-in-depth).
|
[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
|
- **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
|
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`.
|
and receipts included. A PDU filling both is read from `short_message`.
|
||||||
@@ -472,19 +496,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
|
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
|
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
|
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
|
## Server in depth
|
||||||
|
|
||||||
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
|
**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
|
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
|
numbers itself into no message this session can join, which refuses it, or the reassembly buffer or
|
||||||
full, which asks the SMSC to keep it and try again. `sms.answeredOnArrival` says whether the message
|
the unanswered messages are at their bound, which asks the SMSC to keep it and try again.
|
||||||
you hold was answered that way; a segment count cannot, since a peer may number a message one part
|
`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count
|
||||||
of one.
|
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
|
- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and
|
||||||
returns `err` for an `smsId` or a refusing `status`.
|
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
|
- `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the
|
||||||
`submit_sm` responses carried.
|
`submit_sm` responses carried.
|
||||||
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
|
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
|
||||||
@@ -524,7 +549,9 @@ if (err) throw err;
|
|||||||
- `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop
|
- `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop
|
||||||
the link. Guard on the command name, as above, and a failing hook costs only its own request.
|
the link. Guard on the command name, as above, and a failing hook costs only its own request.
|
||||||
- A `Session` you construct yourself takes the same hook as a session option, and that is where a
|
- A `Session` you construct yourself takes the same hook as a session option, and that is where a
|
||||||
peer's bind gets accepted, since a hand-wired session has no bind handling of its own.
|
peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call
|
||||||
|
`session.bound(pduObj.cmdName.slice('bind_'.length), pduObj.params.interface_version)` before
|
||||||
|
answering it, and refuse the bind with `ESME_RBINDFAIL` where that returns `err`.
|
||||||
|
|
||||||
**`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
**`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
||||||
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
|
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
|
||||||
@@ -598,6 +625,13 @@ if (isCommand(pduObj, 'submit_sm')) {
|
|||||||
and hands back the UDH where the PDU carries one.
|
and hands back the UDH where the PDU carries one.
|
||||||
- `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that
|
- `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.
|
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
|
- `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.
|
TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class.
|
||||||
|
|
||||||
@@ -606,6 +640,14 @@ if (isCommand(pduObj, 'submit_sm')) {
|
|||||||
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding`
|
- 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,
|
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.
|
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
|
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
|
||||||
data headers, deliberately malformed bodies.
|
data headers, deliberately malformed bodies.
|
||||||
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
|
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
|
||||||
@@ -625,13 +667,97 @@ if (isCommand(pduObj, 'submit_sm')) {
|
|||||||
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
|
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
|
||||||
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
|
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
|
||||||
| Time and ids | `smppDate`, `smppTime`, `uuidv7` |
|
| 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`. |
|
| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
|
||||||
|
|
||||||
|
## What changed per release
|
||||||
|
|
||||||
|
See [CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
|
||||||
|
|
||||||
## Migrating from larvitsmpp 0.4.0
|
## Migrating from larvitsmpp 0.4.0
|
||||||
|
|
||||||
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
|
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
In priority order, and the order is the point: where two of them pull against each other, the earlier
|
||||||
|
one wins. They do not override the [hard rules](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
|
## Development
|
||||||
|
|
||||||
Everything runs in the container; nothing is installed on the host.
|
Everything runs in the container; nothing is installed on the host.
|
||||||
|
|||||||
@@ -0,0 +1,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,837 @@
|
|||||||
|
# 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.** Maintainer's call, 2026-09-27, on the
|
||||||
|
architecture and product-owner reviews of [#30](https://gitea.larvit.se/larvit/smpp-js/pulls/30). 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. A stream the framer or
|
||||||
|
the codec cannot read, an aborted `close({ signal })` and a peer's own `unbind` do not drain:
|
||||||
|
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. `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.
|
||||||
|
|
||||||
|
- **The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's
|
||||||
|
gap.** Maintainer's call, 2026-09-28. `client()` and `server()` record their bind through
|
||||||
|
`bound()`, the call a hand-wired session makes, so the state has one writer — goal 8. Clearing the state at `teardown()` was rejected: `bindAllows()` and
|
||||||
|
`acceptsOptionalParams()` then answer yes to everything while the link is down, so a
|
||||||
|
receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a
|
||||||
|
pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind
|
||||||
|
type to the same peer.
|
||||||
|
|
||||||
|
- **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()` marks the session attached the moment a socket is
|
||||||
|
handed over, one round trip before the bind is answered, so gating on that let a send arriving in
|
||||||
|
that window go out unbound and come back `ESME_RINVBNDSTS`. 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. The retry in `requestPastDrain()` asks `gate.awaitsNextLink()` rather than `canCarry()`,
|
||||||
|
which also reads the socket: a loop condition the gate does not gate on spins against a gate that
|
||||||
|
admits it straight back.
|
||||||
|
|
||||||
|
|
||||||
|
## Internals and tests
|
||||||
|
|
||||||
|
- **#30 merged under the comprehension floor, and Locality is the next work.** Maintainer's call,
|
||||||
|
2026-09-27. A four-seat scoring run, depth 1, read the project at 6, 6, 7 and 6 (mean 6.25), every
|
||||||
|
seat capped by Locality in the held-message and shutdown code #30 does not touch, where the floor
|
||||||
|
is 7.0. The chunks after #30 lift Locality to 7 before any other work. Serves goal 8's
|
||||||
|
reshapeable internals, which a reader has to understand before reshaping. Valid until a scoring
|
||||||
|
run reads 7.0 or above.
|
||||||
|
|
||||||
|
- **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
|
1. A worktree on a branch off `origin/main` (never `origin/v0.4.0`, the 0.4.0 code), named
|
||||||
for the defect.
|
for the defect.
|
||||||
2. Regression tests in `test/` first, naming the behaviour with the reproducer from the findings;
|
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.
|
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
|
3. `/larv-review` on the branch, with the pull request based on `main`. When it marks the PR
|
||||||
ready, fast-forward it.
|
ready, fast-forward it.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# interop-tests
|
# 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
|
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.
|
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 |
|
| 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/` |
|
| **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 |
|
| **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/` |
|
| **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
|
timeout: 2s
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Network owner for every dumbclient-* service and the capture sidecar below (see the comment on
|
# Network owner for every dumbclient-* service and the capture sidecar below: all four are pure
|
||||||
# `capture`): all four are pure outbound TCP clients with nothing of their own listening, so
|
# outbound TCP clients, so sharing one netns is only a source-IP detail. It never exits, because a
|
||||||
# sharing one netns is only ever a source-IP detail, never a port collision.
|
# 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:
|
dumbclient-w2000:
|
||||||
build: ./interop-tests/peers/dumbclient
|
build: ./interop-tests/peers/dumbclient
|
||||||
image: interop-dumbclient:de0334b
|
image: interop-dumbclient:de0334b
|
||||||
<<: *log-limits
|
<<: *log-limits
|
||||||
|
network_mode: "service:dumbclient-netns"
|
||||||
command: ["conf/window2000.yml"]
|
command: ["conf/window2000.yml"]
|
||||||
healthcheck: *dumbclient-healthcheck
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
depends_on:
|
||||||
|
dumbclient-netns:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
# S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing
|
# 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:
|
dumbclient-w500:
|
||||||
build: ./interop-tests/peers/dumbclient
|
build: ./interop-tests/peers/dumbclient
|
||||||
image: interop-dumbclient:de0334b
|
image: interop-dumbclient:de0334b
|
||||||
<<: *log-limits
|
<<: *log-limits
|
||||||
network_mode: "service:dumbclient-w2000"
|
network_mode: "service:dumbclient-netns"
|
||||||
command: ["conf/window500.yml"]
|
command: ["conf/window500.yml"]
|
||||||
healthcheck: *dumbclient-healthcheck
|
healthcheck: *dumbclient-healthcheck
|
||||||
depends_on:
|
depends_on:
|
||||||
dumbclient-w2000:
|
dumbclient-netns:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
# S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends
|
# 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
|
build: ./interop-tests/peers/dumbclient
|
||||||
image: interop-dumbclient:de0334b
|
image: interop-dumbclient:de0334b
|
||||||
<<: *log-limits
|
<<: *log-limits
|
||||||
network_mode: "service:dumbclient-w2000"
|
network_mode: "service:dumbclient-netns"
|
||||||
environment:
|
environment:
|
||||||
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
|
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
|
||||||
command: ["conf/idle.yml"]
|
command: ["conf/idle.yml"]
|
||||||
healthcheck: *dumbclient-healthcheck
|
healthcheck: *dumbclient-healthcheck
|
||||||
depends_on:
|
depends_on:
|
||||||
dumbclient-w2000:
|
dumbclient-netns:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
# The long soak: the longest run the time-box allows, fast handler, watched for anything that
|
# 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
|
build: ./interop-tests/peers/dumbclient
|
||||||
image: interop-dumbclient:de0334b
|
image: interop-dumbclient:de0334b
|
||||||
<<: *log-limits
|
<<: *log-limits
|
||||||
network_mode: "service:dumbclient-w2000"
|
network_mode: "service:dumbclient-netns"
|
||||||
command: ["conf/soak.yml"]
|
command: ["conf/soak.yml"]
|
||||||
healthcheck: *dumbclient-healthcheck
|
healthcheck: *dumbclient-healthcheck
|
||||||
depends_on:
|
depends_on:
|
||||||
dumbclient-w2000:
|
dumbclient-netns:
|
||||||
condition: service_started
|
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
|
# sees all four conversations with node:2775 - the same pattern compose.kannel.yaml uses for its
|
||||||
# four bearerbox variants, one namespace deeper.
|
# four bearerbox variants, one namespace deeper.
|
||||||
capture:
|
capture:
|
||||||
image: nicolaka/netshoot:v0.16
|
image: nicolaka/netshoot:v0.16
|
||||||
network_mode: "service:dumbclient-w2000"
|
network_mode: "service:dumbclient-netns"
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
depends_on:
|
depends_on:
|
||||||
dumbclient-w2000:
|
dumbclient-netns:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"]
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./interop-tests/captures:/captures
|
- ./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
|
// 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
|
// 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
|
// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a
|
||||||
// what that constant, rather than maxOutstanding, turns out to be the one that interacts with a
|
// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent
|
||||||
// peer's window.
|
// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled.
|
||||||
describe('S9 - bounded window against a slowed handler', () => {
|
const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry';
|
||||||
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);
|
|
||||||
|
|
||||||
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 s = statsFor(name);
|
||||||
|
const expectedCount = 20_000 - throttled(name);
|
||||||
|
|
||||||
assert.equal(s.arrived, expectedCount);
|
assert.equal(s.arrived, expectedCount);
|
||||||
assert.equal(s.answered, 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 () => {
|
test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => {
|
||||||
await waitFor(() => (statsFor('dumb-w2000').answered >= 20_000 ? true : undefined), 180_000);
|
assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000');
|
||||||
|
assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000);
|
||||||
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);
|
|
||||||
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
|
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
|
// 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
|
// grows without bound (held messages, listeners, memory). Bounded by wall-clock, and asserting that
|
||||||
// target count: smpp-dumb-client's own TX-tracking window bookkeeping stalls under sustained load
|
// arrived/answered stay in lockstep.
|
||||||
// (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.
|
|
||||||
describe('Long soak', () => {
|
describe('Long soak', () => {
|
||||||
const SOAK_DURATION_MS = 300_000;
|
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
|
`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
|
(`<<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
|
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
|
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
|
unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and
|
||||||
substitutes for S6 and (partially) S8 - see below.
|
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
|
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
|
`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,
|
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.
|
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:
|
The four scenarios share one network namespace, owned by `dumbclient-netns`, a container that
|
||||||
"service:dumbclient-w2000"`) - they are pure outbound clients with nothing of their own listening,
|
never exits - they are pure outbound clients with nothing of their own listening, so the only shared
|
||||||
so the only shared cost is a source IP, and one capture sidecar sees all four conversations with
|
cost is a source IP, and one capture sidecar sees all four conversations with `node:2775` the same
|
||||||
`node:2775` the same way `compose.kannel.yaml`'s does for its four bearerbox variants.
|
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
|
Runs 1 and 2 had `dumbclient-w2000` own the namespace. It exits once it has sent its 20,000, which
|
||||||
a fast, immediate-response handler and a window of 100 - nothing our server should ever have
|
took every other client's network with it: the soak's responses stopped arriving, and its log filled
|
||||||
trouble draining - the peer's own reported in-flight count (`GetTrackQueueSize`, read from
|
with `Expired TX packet` lines (`libsmpp`'s 7000ms `TX_MAX_TIMEOUT_MS`). Those runs read that as the
|
||||||
`len(TrackTX)`) gets stuck pinned at the window within the first minute, and its log fills with
|
peer's own window bookkeeping stalling; run 3 (2026-09-26), with the namespace owned by a container
|
||||||
`Expired TX packet` lines (`libsmpp`'s hardcoded, non-configurable 7000ms `TX_MAX_TIMEOUT_MS`) -
|
that outlives them all, reached 173,820 soak messages in 300s where run 2 reached 22,440. The soak
|
||||||
throughput drops from ~500/s to a trickle of tens per second, gated by how many tracked entries
|
stays bounded by wall-clock (5 minutes), asserting every arrival answered, nothing duplicated, and
|
||||||
individually cross that 7s mark each second rather than by real responses being matched. Our own
|
the memory shape.
|
||||||
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.
|
|
||||||
|
|
||||||
One test-harness bug found and fixed between the two runs below, not a library defect: the S6 test's
|
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
|
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
|
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.
|
`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
|
Three 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
|
the S6 harness bug above; run 2 fixed it; run 3, with the namespace owner above and the held-message
|
||||||
reported below. `smppload.test.ts` passed on every run it was given (three, across the investigation
|
throttle in `src/`, is the one Scenarios reports. The capture figures below are run 2's.
|
||||||
above); its one scenario needs no repeat - a second run reproduces the identical corrupted PDU,
|
`smppload.test.ts` passed on every run it was given (three, across the investigation above); its one
|
||||||
adding nothing.
|
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
|
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
|
## Throughput and memory
|
||||||
|
|
||||||
`dumb-w500` and `dumb-w2000` (S9) both ran to their full 20,000-message count in ~44s each,
|
Run 3. `dumb-w500` ran to its full 20,000 in ~44s against a handler serialised to answer roughly
|
||||||
concurrently, against a handler serialised to answer roughly one message every 2ms
|
one message every 2ms (`SLOW_HANDLER_DELAY_MS`), `peakOutstanding` exactly 500. `dumb-w2000`, run
|
||||||
(`SLOW_HANDLER_DELAY_MS`) - `peakOutstanding` read exactly 500 and exactly 2000, the two configured
|
concurrently, held exactly 1000 and was throttled for the rest.
|
||||||
windows, confirming the peer never let more than its own window ride at once.
|
|
||||||
|
|
||||||
The soak (fast, immediate-response handler; window 100) reached 22,440 `submit_sm` over its fixed
|
The soak (fast, immediate-response handler; window 100) reached 173,820 `submit_sm` over its fixed
|
||||||
300s observation window - about 75/s, well under the peer's own configured `rate: 500` and under
|
300s, about 580/s, `peakOutstanding` 15. Sampled every 5s across the whole run (69 samples over
|
||||||
what our server can sustain (see Setup: `smpp-dumb-client`'s own TX-tracking bookkeeping is the
|
340s, all four scenarios combined, the harness's own per-message bookkeeping included): rss
|
||||||
ceiling here, not our server - `peakOutstanding` stayed at 25 throughout). Sampled every 5s across
|
first=165MiB, min=165MiB, max=298MiB, last=298MiB, heapUsed at the last sample 81MiB.
|
||||||
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.
|
|
||||||
|
|
||||||
## Scenarios (PLAN.md)
|
## Scenarios (PLAN.md)
|
||||||
|
|
||||||
| Id | Result | Evidence |
|
| 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 |
|
| 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 |
|
| 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 | `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 |
|
| 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 | 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) |
|
| 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 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 |
|
| 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 |
|
| 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
|
## 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
|
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
|
(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
|
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
|
that never gets as far as a readable PDU.
|
||||||
(see Setup) - our own `arrived`/`answered`/`peakOutstanding` counters stayed clean throughout every
|
|
||||||
run.
|
|
||||||
|
|
||||||
## Peer quirks
|
## Peer quirks
|
||||||
|
|
||||||
- **smppload's `bind_transceiver` is corrupted on the wire** - see Setup. Not chased past `oserl`'s
|
- **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.
|
`pack/2` (which is correct on inspection) given the time-box.
|
||||||
- **`smpp-dumb-client`'s window bookkeeping stalls under sustained load, throttling its own
|
- **`smpp-dumb-client` treats `ESME_RTHROTTLED` as final** - a throttled message counts as sent
|
||||||
throughput far below what a promptly-answering server can sustain** - see Setup. Its `enquire_link`
|
and is never resubmitted. Its `enquire_link` interval (10s once bound as an ESME) is hardcoded
|
||||||
interval (10s once bound as an ESME) is also hardcoded (`smpp.go`, `enquireSender(10)`), not
|
(`smpp.go`, `enquireSender(10)`), not exposed through `config.yml` at all - the no-ping binary
|
||||||
exposed through `config.yml` at all - the no-ping binary built for S6 patches the call site out
|
built for S6 patches the call site out rather than configuring it.
|
||||||
rather than configuring it.
|
|
||||||
- **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see
|
- **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see
|
||||||
Setup.
|
Setup.
|
||||||
|
|
||||||
@@ -156,7 +162,3 @@ run.
|
|||||||
|
|
||||||
- Whether smppload's bind corruption is in `oserl`'s `gen_esme_session`/`smpp_session` send path
|
- 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.
|
(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("/bind", Driver::handleBind);
|
||||||
server.createContext("/unbind", Driver::handleUnbind);
|
server.createContext("/unbind", Driver::handleUnbind);
|
||||||
server.createContext("/submit", Driver::handleSubmit);
|
server.createContext("/submit", Driver::handleSubmit);
|
||||||
|
server.createContext("/load", Driver::handleLoad);
|
||||||
server.createContext("/windowBurst", Driver::handleWindowBurst);
|
server.createContext("/windowBurst", Driver::handleWindowBurst);
|
||||||
server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
|
server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
|
||||||
server.setExecutor(null);
|
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. */
|
/** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */
|
||||||
private static void handleWindowBurst(HttpExchange exchange) {
|
private static void handleWindowBurst(HttpExchange exchange) {
|
||||||
Map<String, String> p = queryParams(exchange);
|
Map<String, String> p = queryParams(exchange);
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
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
|
* 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("/unbind", Driver::handleUnbind);
|
||||||
server.createContext("/enquireLink", Driver::handleEnquireLink);
|
server.createContext("/enquireLink", Driver::handleEnquireLink);
|
||||||
server.createContext("/submit", Driver::handleSubmit);
|
server.createContext("/submit", Driver::handleSubmit);
|
||||||
|
server.createContext("/load", Driver::handleLoad);
|
||||||
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
|
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
|
||||||
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
|
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
|
||||||
server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace"));
|
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,
|
private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding,
|
||||||
java.util.List<Map<String, Object>> segments) throws Exception {
|
java.util.List<Map<String, Object>> segments) throws Exception {
|
||||||
SubmitSmResult r = session.submitShortMessage("CMT",
|
SubmitSmResult r = session.submitShortMessage("CMT",
|
||||||
|
|||||||
@@ -62,6 +62,6 @@ describe('smppload (blocked)', () => {
|
|||||||
await waitFor(() => (closed ? true : undefined), 5000);
|
await waitFor(() => (closed ? true : undefined), 5000);
|
||||||
assert.equal(closed, true);
|
assert.equal(closed, true);
|
||||||
assert.ok(bound);
|
assert.ok(bound);
|
||||||
assert.equal(bound.loggedIn, false);
|
assert.equal(bound.boundAs, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+34
-9
@@ -8,7 +8,7 @@ export type { BindType };
|
|||||||
|
|
||||||
import { ReconnectLoop } from './reconnect-loop.ts';
|
import { ReconnectLoop } from './reconnect-loop.ts';
|
||||||
import { Session } from './session.ts';
|
import { Session } from './session.ts';
|
||||||
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
|
import { checkSessionOptions } from './session-options.ts';
|
||||||
import { connect as netConnect } from 'node:net';
|
import { connect as netConnect } from 'node:net';
|
||||||
import { connect as tlsConnect } from 'node:tls';
|
import { connect as tlsConnect } from 'node:tls';
|
||||||
import { defaultInterfaceVersion } from './defs/constants.ts';
|
import { defaultInterfaceVersion } from './defs/constants.ts';
|
||||||
@@ -22,6 +22,7 @@ export type ClientOptions = {
|
|||||||
addrNpi?: number;
|
addrNpi?: number;
|
||||||
addrTon?: number;
|
addrTon?: number;
|
||||||
bindType?: BindType;
|
bindType?: BindType;
|
||||||
|
connectTimeout?: number | false;
|
||||||
enquireLinkInterval?: number;
|
enquireLinkInterval?: number;
|
||||||
host?: string;
|
host?: string;
|
||||||
idleTimeout?: number;
|
idleTimeout?: number;
|
||||||
@@ -42,6 +43,7 @@ export type ClientOptions = {
|
|||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
bindType: 'transceiver',
|
bindType: 'transceiver',
|
||||||
|
connectTimeout: 10_000,
|
||||||
enquireLinkInterval: 20_000,
|
enquireLinkInterval: 20_000,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
|
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
|
||||||
@@ -52,7 +54,30 @@ const defaults = {
|
|||||||
username: 'user',
|
username: 'user',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function armConnectTimeout(
|
||||||
|
sock: Socket,
|
||||||
|
connectTimeout: number | false,
|
||||||
|
target: { peer: string; secure: boolean },
|
||||||
|
settle: (result: Result<{ sock: Socket }>) => void,
|
||||||
|
): NodeJS.Timeout | undefined {
|
||||||
|
if (connectTimeout === false) return undefined;
|
||||||
|
|
||||||
|
let phase = `connecting to ${target.peer}`;
|
||||||
|
|
||||||
|
if (target.secure) {
|
||||||
|
sock.once('connect', () => { phase = `completing the TLS handshake with ${target.peer}`; });
|
||||||
|
}
|
||||||
|
|
||||||
|
return setTimeout(() => {
|
||||||
|
sock.destroy();
|
||||||
|
settle({
|
||||||
|
err: new Error(`Timed out ${phase} after ${String(connectTimeout)} ms; raise connectTimeout or set it to false`),
|
||||||
|
});
|
||||||
|
}, connectTimeout).unref();
|
||||||
|
}
|
||||||
|
|
||||||
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
||||||
|
const connectTimeout = options.connectTimeout ?? defaults.connectTimeout;
|
||||||
const host = options.host ?? defaults.host;
|
const host = options.host ?? defaults.host;
|
||||||
const port = options.port ?? defaults.port;
|
const port = options.port ?? defaults.port;
|
||||||
const secure = options.tls !== undefined && options.tls !== false;
|
const secure = options.tls !== undefined && options.tls !== false;
|
||||||
@@ -77,11 +102,14 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = (result: Result<{ sock: Socket }>): void => {
|
const timer = armConnectTimeout(sock, connectTimeout, { peer: `${host}:${String(port)}`, secure }, settle);
|
||||||
|
|
||||||
|
function settle(result: Result<{ sock: Socket }>): void {
|
||||||
|
clearTimeout(timer);
|
||||||
sock.removeListener('error', onError);
|
sock.removeListener('error', onError);
|
||||||
signal?.removeEventListener('abort', onAbort);
|
signal?.removeEventListener('abort', onAbort);
|
||||||
resolve(result);
|
resolve(result);
|
||||||
};
|
}
|
||||||
|
|
||||||
function onError(err: Error): void {
|
function onError(err: Error): void {
|
||||||
settle({ err });
|
settle({ err });
|
||||||
@@ -146,13 +174,10 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
|
|||||||
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
|
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
|
const recorded = session.bound(bindType, sent.pduObj.tlvs.sc_interface_version?.tagValue);
|
||||||
|
|
||||||
|
if (recorded.err) return recorded;
|
||||||
|
|
||||||
session.boundAs = bindType;
|
|
||||||
session.loggedIn = true;
|
|
||||||
session.peerInterfaceVersion = typeof declared === 'number'
|
|
||||||
? declared
|
|
||||||
: undeclaredInterfaceVersion;
|
|
||||||
session.log.info('client - bound', { bindType, systemId });
|
session.log.info('client - bound', { bindType, systemId });
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from '
|
|||||||
type CommandSpec = {
|
type CommandSpec = {
|
||||||
id: number;
|
id: number;
|
||||||
params?: Record<string, WireType>;
|
params?: Record<string, WireType>;
|
||||||
tlvMap?: Record<string, string>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const bindParams = {
|
const bindParams = {
|
||||||
@@ -59,7 +58,6 @@ const specs = {
|
|||||||
broadcast_sm_resp: {
|
broadcast_sm_resp: {
|
||||||
id: 0x80000111,
|
id: 0x80000111,
|
||||||
params: { message_id: cstring },
|
params: { message_id: cstring },
|
||||||
tlvMap: { broadcast_area_identifier: 'failed_broadcast_area_identifier' },
|
|
||||||
},
|
},
|
||||||
cancel_broadcast_sm: {
|
cancel_broadcast_sm: {
|
||||||
id: 0x00000113,
|
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 type { Result } from '../result.ts';
|
||||||
import { tlv } from './types.ts';
|
import { tlv } from './types.ts';
|
||||||
|
|
||||||
export type TlvDefinition = {
|
/** Only a tag read as octets or as a number may repeat, since its occurrences are listed as one of those. */
|
||||||
id: number;
|
type Definition<Tag> = { id: number; multiple?: false; tag: Tag; type: WireType<Buffer | number | string> }
|
||||||
multiple?: boolean;
|
| { id: number; multiple: true; tag: Tag; type: WireType<Buffer> | WireType<number> };
|
||||||
tag: string;
|
|
||||||
type: WireType;
|
export type TlvDefinition = Definition<string>;
|
||||||
};
|
|
||||||
|
|
||||||
/** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */
|
/** 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 } }>(
|
const tlvSpecs = <T extends { [K in keyof T]: Definition<K> }>(definitions: T): T => definitions;
|
||||||
definitions: T,
|
|
||||||
): T => definitions;
|
|
||||||
|
|
||||||
// Ordered by tag id, mirroring the SMPP 5.0 TLV table.
|
// Ordered by tag id, mirroring the SMPP 5.0 TLV table.
|
||||||
const specs = tlvSpecs({
|
const specs = tlvSpecs({
|
||||||
@@ -82,15 +79,18 @@ const specs = tlvSpecs({
|
|||||||
its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer },
|
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> = {
|
export type TlvName = keyof Specs;
|
||||||
...specs,
|
|
||||||
// Alternate spellings; the definition behind each keeps its canonical name.
|
// SMPP 5.0's other spellings, which only name the tag to key instead.
|
||||||
alert_on_msg_delivery: specs.alert_on_message_delivery,
|
const alternates: Record<string, TlvName> = {
|
||||||
failed_broadcast_area_identifier: specs.broadcast_area_identifier,
|
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> = {};
|
export const tlvsById: Record<number, TlvDefinition> = {};
|
||||||
|
|
||||||
for (const definition of Object.values<TlvDefinition>(specs)) {
|
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. */
|
/** 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 = {
|
type Repeated<K extends TlvName, V> = Specs[K] extends { multiple: true } ? V[] : V;
|
||||||
tagId: number;
|
|
||||||
tagName: string | undefined;
|
|
||||||
tagValue: ParamValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlvInput = {
|
type WireValue<K extends TlvName> = Specs[K]['type']['default'];
|
||||||
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
|
|
||||||
tagId?: number | undefined;
|
|
||||||
tagValue: ParamValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
type ReadValue<K extends TlvName> = Repeated<K, WireValue<K>>;
|
||||||
const tagId = input.tagId ?? tlvs[name]?.id;
|
|
||||||
|
|
||||||
if (tagId === undefined) {
|
/** A lone text field also takes a number, and an octet field text, which goes out as latin1. */
|
||||||
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
|
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) {
|
const tagId = Number(name);
|
||||||
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
|
|
||||||
|
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. */
|
/** 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[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
for (const [name, input] of Object.entries(inputs ?? {})) {
|
for (const [name, input] of Object.entries<unknown>(inputs ?? {})) {
|
||||||
const tag = tagIdOf(name, input);
|
const tag = entryOf(name, input);
|
||||||
|
|
||||||
if (tag.err) return { err: tag.err };
|
if (tag.err) return { err: tag.err };
|
||||||
|
|
||||||
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
|
const definition = tlvsById[tag.tagId];
|
||||||
const sized = type.size(input.tagValue);
|
const values = occurrences(tag.tagValue, definition?.multiple === true);
|
||||||
|
|
||||||
if (sized.err) {
|
if (values.err) return { err: new Error(`TLV "${name}": ${values.err.message}`) };
|
||||||
return { err: new Error(`TLV "${name}": ${sized.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 };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
+143
-54
@@ -1,4 +1,5 @@
|
|||||||
import type { Result, VoidResult } from '../result.ts';
|
import type { Result, VoidResult } from '../result.ts';
|
||||||
|
import { unencodableText } from './encodings.ts';
|
||||||
|
|
||||||
export type DestAddress =
|
export type DestAddress =
|
||||||
| { dest_addr_npi: number; dest_addr_ton: number; destination_addr: string }
|
| { 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;
|
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
|
* 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.
|
* 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]". */
|
/** 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 === 'string') return value;
|
||||||
if (typeof value === 'number') return value.toString();
|
if (typeof value === 'number') return value.toString();
|
||||||
if (Buffer.isBuffer(value)) return value.toString('ascii');
|
if (Buffer.isBuffer(value)) return value.toString('latin1');
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -37,6 +56,11 @@ export function paramNumber(value: ParamValue | undefined, fallback: number): nu
|
|||||||
return typeof value === 'number' ? value : fallback;
|
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 {
|
function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined {
|
||||||
if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
|
if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
|
||||||
return new Error(
|
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 }> {
|
function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
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) {
|
if (value < 0 || value > max) {
|
||||||
@@ -79,11 +103,46 @@ function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wantText(value: ParamValue): Result<{ text: string }> {
|
function pastLatin1(text: string): { err: Error } | undefined {
|
||||||
if (typeof value === 'string') return { text: value };
|
const index = text.search(/[\u0100-\uFFFF]/);
|
||||||
if (typeof value === 'number') return { text: value.toString() };
|
|
||||||
|
|
||||||
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 }> {
|
function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
|
||||||
@@ -91,7 +150,7 @@ function wantBytes(value: ParamValue): Result<{ bytes: Buffer }> {
|
|||||||
|
|
||||||
const { err, text } = wantText(value);
|
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 {
|
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 {
|
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 };
|
if (err) return { err };
|
||||||
|
|
||||||
buffer.write(text, offset, 'ascii');
|
buffer.write(text, offset, 'latin1');
|
||||||
buffer[offset + text.length] = 0;
|
buffer[offset + text.length] = 0;
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@@ -248,7 +307,7 @@ export const string: WireType<string> = {
|
|||||||
|
|
||||||
if (err) return { err };
|
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) {
|
size(value) {
|
||||||
const { err, text } = wantText(value);
|
const { err, text } = wantText(value);
|
||||||
@@ -271,7 +330,7 @@ export const string: WireType<string> = {
|
|||||||
if (rangeErr) return { err: rangeErr };
|
if (rangeErr) return { err: rangeErr };
|
||||||
|
|
||||||
buffer.writeUInt8(text.length, offset);
|
buffer.writeUInt8(text.length, offset);
|
||||||
buffer.write(text, offset + 1, 'ascii');
|
buffer.write(text, offset + 1, 'latin1');
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
@@ -288,12 +347,12 @@ export const cstring: WireType<string> = {
|
|||||||
default: '',
|
default: '',
|
||||||
read: readCstring,
|
read: readCstring,
|
||||||
size(value) {
|
size(value) {
|
||||||
const { err, text } = wantText(value);
|
const { err, text } = wantCstringText(value);
|
||||||
|
|
||||||
return err ? { err } : { size: text.length + 1 };
|
return err ? { err } : { size: text.length + 1 };
|
||||||
},
|
},
|
||||||
write(value, buffer, offset) {
|
write(value, buffer, offset) {
|
||||||
const { err, text } = wantText(value);
|
const { err, text } = wantCstringText(value);
|
||||||
|
|
||||||
return err ? { err } : writeCstring(text, buffer, offset);
|
return err ? { err } : writeCstring(text, buffer, offset);
|
||||||
},
|
},
|
||||||
@@ -326,14 +385,52 @@ export const buffer: WireType<Buffer> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function sizeDestAddresses(addresses: DestAddress[]): number {
|
function writeSizedCstring(value: ParamValue, buf: Buffer, offset: number): Result<{ size: number }> {
|
||||||
|
const { err, size } = cstring.size(value);
|
||||||
|
|
||||||
|
if (err) return { err };
|
||||||
|
|
||||||
|
const written = cstring.write(value, buf, offset);
|
||||||
|
|
||||||
|
return written.err ? { err: written.err } : { size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeDestAddresses(addresses: DestAddress[]): Result<{ size: number }> {
|
||||||
let size = 1;
|
let size = 1;
|
||||||
|
|
||||||
for (const dest of addresses) {
|
for (const dest of addresses) {
|
||||||
size += 'dl_name' in dest ? dest.dl_name.length + 2 : dest.destination_addr.length + 4;
|
const [header, text] = 'dl_name' in dest ? [1, cstring.size(dest.dl_name)] : [3, cstring.size(dest.destination_addr)];
|
||||||
|
|
||||||
|
if (text.err) return { err: text.err };
|
||||||
|
|
||||||
|
size += header + text.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
return size;
|
return { size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDestAddress(dest: DestAddress, buf: Buffer, offset: number): Result<{ size: number }> {
|
||||||
|
if ('dl_name' in dest) {
|
||||||
|
buf.writeUInt8(2, offset);
|
||||||
|
|
||||||
|
const name = writeSizedCstring(dest.dl_name, buf, offset + 1);
|
||||||
|
|
||||||
|
return name.err ? { err: name.err } : { size: 1 + name.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.writeUInt8(1, offset);
|
||||||
|
|
||||||
|
const ton = writeInt8(dest.dest_addr_ton, buf, offset + 1);
|
||||||
|
|
||||||
|
if (ton.err) return { err: ton.err };
|
||||||
|
|
||||||
|
const npi = writeInt8(dest.dest_addr_npi, buf, offset + 2);
|
||||||
|
|
||||||
|
if (npi.err) return { err: npi.err };
|
||||||
|
|
||||||
|
const addr = writeSizedCstring(dest.destination_addr, buf, offset + 3);
|
||||||
|
|
||||||
|
return addr.err ? { err: addr.err } : { size: 3 + addr.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dest_address_array: WireType<DestAddress[]> = {
|
export const dest_address_array: WireType<DestAddress[]> = {
|
||||||
@@ -382,14 +479,18 @@ export const dest_address_array: WireType<DestAddress[]> = {
|
|||||||
size(value) {
|
size(value) {
|
||||||
const { addresses, err } = wantDestAddresses(value);
|
const { addresses, err } = wantDestAddresses(value);
|
||||||
|
|
||||||
return err ? { err } : { size: sizeDestAddresses(addresses) };
|
return err ? { err } : sizeDestAddresses(addresses);
|
||||||
},
|
},
|
||||||
write(value, buf, offset) {
|
write(value, buf, offset) {
|
||||||
const { addresses, err } = wantDestAddresses(value);
|
const { addresses, err } = wantDestAddresses(value);
|
||||||
|
|
||||||
if (err) return { err };
|
if (err) return { err };
|
||||||
|
|
||||||
const rangeErr = outOfRange(buf, offset, sizeDestAddresses(addresses));
|
const total = sizeDestAddresses(addresses);
|
||||||
|
|
||||||
|
if (total.err) return { err: total.err };
|
||||||
|
|
||||||
|
const rangeErr = outOfRange(buf, offset, total.size);
|
||||||
|
|
||||||
if (rangeErr) return { err: rangeErr };
|
if (rangeErr) return { err: rangeErr };
|
||||||
|
|
||||||
@@ -398,45 +499,29 @@ export const dest_address_array: WireType<DestAddress[]> = {
|
|||||||
if (count.err) return { err: count.err };
|
if (count.err) return { err: count.err };
|
||||||
|
|
||||||
for (const dest of addresses) {
|
for (const dest of addresses) {
|
||||||
if ('dl_name' in dest) {
|
const written = writeDestAddress(dest, buf, offset);
|
||||||
buf.writeUInt8(2, offset++);
|
|
||||||
|
|
||||||
const name = writeCstring(dest.dl_name, buf, offset);
|
if (written.err) return { err: written.err };
|
||||||
|
|
||||||
if (name.err) return { err: name.err };
|
offset += written.size;
|
||||||
|
|
||||||
offset += dest.dl_name.length + 1;
|
|
||||||
} else {
|
|
||||||
buf.writeUInt8(1, offset++);
|
|
||||||
|
|
||||||
const ton = writeInt8(dest.dest_addr_ton, buf, offset++);
|
|
||||||
|
|
||||||
if (ton.err) return { err: ton.err };
|
|
||||||
|
|
||||||
const npi = writeInt8(dest.dest_addr_npi, buf, offset++);
|
|
||||||
|
|
||||||
if (npi.err) return { err: npi.err };
|
|
||||||
|
|
||||||
const addr = writeCstring(dest.destination_addr, buf, offset);
|
|
||||||
|
|
||||||
if (addr.err) return { err: addr.err };
|
|
||||||
|
|
||||||
offset += dest.destination_addr.length + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function sizeUnsuccessSmes(smes: UnsuccessSme[]): number {
|
function sizeUnsuccessSmes(smes: UnsuccessSme[]): Result<{ size: number }> {
|
||||||
let size = 1;
|
let size = 1;
|
||||||
|
|
||||||
for (const sme of smes) {
|
for (const sme of smes) {
|
||||||
size += sme.destination_addr.length + 7;
|
const addr = cstring.size(sme.destination_addr);
|
||||||
|
|
||||||
|
if (addr.err) return { err: addr.err };
|
||||||
|
|
||||||
|
size += addr.size + 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
return size;
|
return { size };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
||||||
@@ -481,14 +566,18 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
|||||||
size(value) {
|
size(value) {
|
||||||
const { err, smes } = wantUnsuccessSmes(value);
|
const { err, smes } = wantUnsuccessSmes(value);
|
||||||
|
|
||||||
return err ? { err } : { size: sizeUnsuccessSmes(smes) };
|
return err ? { err } : sizeUnsuccessSmes(smes);
|
||||||
},
|
},
|
||||||
write(value, buf, offset) {
|
write(value, buf, offset) {
|
||||||
const { err, smes } = wantUnsuccessSmes(value);
|
const { err, smes } = wantUnsuccessSmes(value);
|
||||||
|
|
||||||
if (err) return { err };
|
if (err) return { err };
|
||||||
|
|
||||||
const rangeErr = outOfRange(buf, offset, sizeUnsuccessSmes(smes));
|
const total = sizeUnsuccessSmes(smes);
|
||||||
|
|
||||||
|
if (total.err) return { err: total.err };
|
||||||
|
|
||||||
|
const rangeErr = outOfRange(buf, offset, total.size);
|
||||||
|
|
||||||
if (rangeErr) return { err: rangeErr };
|
if (rangeErr) return { err: rangeErr };
|
||||||
|
|
||||||
@@ -505,11 +594,11 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
|||||||
|
|
||||||
if (npi.err) return { err: npi.err };
|
if (npi.err) return { err: npi.err };
|
||||||
|
|
||||||
const addr = writeCstring(sme.destination_addr, buf, offset);
|
const addr = writeSizedCstring(sme.destination_addr, buf, offset);
|
||||||
|
|
||||||
if (addr.err) return { err: addr.err };
|
if (addr.err) return { err: addr.err };
|
||||||
|
|
||||||
offset += sme.destination_addr.length + 1;
|
offset += addr.size;
|
||||||
|
|
||||||
const status = writeInt32(sme.error_status_code, buf, offset);
|
const status = writeInt32(sme.error_status_code, buf, offset);
|
||||||
|
|
||||||
@@ -538,15 +627,15 @@ export const tlv = {
|
|||||||
? offset + length
|
? offset + length
|
||||||
: terminator;
|
: terminator;
|
||||||
|
|
||||||
return { bytesRead: length, value: buf.toString('ascii', offset, end) };
|
return { bytesRead: length, value: buf.toString('latin1', offset, end) };
|
||||||
},
|
},
|
||||||
size(value: ParamValue) {
|
size(value: ParamValue) {
|
||||||
const { err, text } = wantText(value);
|
const { err, text } = wantCstringText(value);
|
||||||
|
|
||||||
return err ? { err } : { size: text.length + 1 };
|
return err ? { err } : { size: text.length + 1 };
|
||||||
},
|
},
|
||||||
write(value: ParamValue, buf: Buffer, offset: number) {
|
write(value: ParamValue, buf: Buffer, offset: number) {
|
||||||
const { err, text } = wantText(value);
|
const { err, text } = wantCstringText(value);
|
||||||
|
|
||||||
return err ? { err } : writeCstring(text, buf, offset);
|
return err ? { err } : writeCstring(text, buf, offset);
|
||||||
},
|
},
|
||||||
@@ -559,7 +648,7 @@ export const tlv = {
|
|||||||
read(buf: Buffer, offset: number, length = 0) {
|
read(buf: Buffer, offset: number, length = 0) {
|
||||||
const err = outOfRange(buf, offset, length);
|
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) {
|
size(value: ParamValue) {
|
||||||
const { err, text } = wantText(value);
|
const { err, text } = wantText(value);
|
||||||
@@ -575,7 +664,7 @@ export const tlv = {
|
|||||||
|
|
||||||
if (rangeErr) return { err: rangeErr };
|
if (rangeErr) return { err: rangeErr };
|
||||||
|
|
||||||
buf.write(text, offset, 'ascii');
|
buf.write(text, offset, 'latin1');
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
import type { MessageState } from './defs/constants.ts';
|
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 { PduObject } from './pdu.ts';
|
||||||
import type { SmsIdFormat } from './sms-id.ts';
|
import type { SmsIdFormat } from './sms-id.ts';
|
||||||
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
|
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
|
||||||
@@ -150,7 +150,7 @@ const smeMessageTypes: readonly number[] = [
|
|||||||
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
|
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;
|
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ function receiptBody(pduObj: PduObject): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function receiptId(
|
function receiptId(
|
||||||
tlvId: ParamValue | undefined,
|
tlvId: TlvValue | undefined,
|
||||||
receipt: Receipt | undefined,
|
receipt: Receipt | undefined,
|
||||||
format: SmsIdFormat,
|
format: SmsIdFormat,
|
||||||
): string | undefined {
|
): 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. */
|
/** The state TLV wins where it names a state we know; an unnameable one leaves the body to say. */
|
||||||
function receiptStatus(
|
function receiptStatus(
|
||||||
tlvState: ParamValue | undefined,
|
tlvState: TlvValue | undefined,
|
||||||
receipt: Receipt | undefined,
|
receipt: Receipt | undefined,
|
||||||
): { statusId: number; statusMsg: MessageState | undefined } {
|
): { statusId: number; statusMsg: MessageState | undefined } {
|
||||||
const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
|
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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-12
@@ -1,8 +1,10 @@
|
|||||||
export type ExpiringGroupsOptions = {
|
export type ExpiringGroupsOptions = {
|
||||||
max: number;
|
max: number;
|
||||||
|
/** Enforced by weigh() alone; set() never evicts. */
|
||||||
|
maxWeight?: number | undefined;
|
||||||
/** Injected so expiry can be exercised without a wall clock. */
|
/** Injected so expiry can be exercised without a wall clock. */
|
||||||
now?: (() => number) | undefined;
|
now?: (() => number) | undefined;
|
||||||
/** Runs on the sweeper's own timer; the owner reports whatever it takes out. */
|
/** Must call takeExpired(): the timer itself removes nothing. */
|
||||||
onSweep: () => void;
|
onSweep: () => void;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
};
|
};
|
||||||
@@ -10,22 +12,23 @@ export type ExpiringGroupsOptions = {
|
|||||||
type Entry<T> = {
|
type Entry<T> = {
|
||||||
deadline: number;
|
deadline: number;
|
||||||
group: T;
|
group: T;
|
||||||
|
weight: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Enforces neither max nor timeout itself: owners check full and call takeExpired(); only weigh() evicts. */
|
||||||
* A capped store of groups that expire. Nothing is dropped silently: the owner takes the expired
|
|
||||||
* and the evicted out itself, so the accounting and the log line stay where the group is understood.
|
|
||||||
*/
|
|
||||||
export class ExpiringGroups<T> {
|
export class ExpiringGroups<T> {
|
||||||
private readonly entries = new Map<string, Entry<T>>();
|
private readonly entries = new Map<string, Entry<T>>();
|
||||||
private readonly max: number;
|
private readonly max: number;
|
||||||
|
private readonly maxWeight: number;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
private readonly onSweep: () => void;
|
private readonly onSweep: () => void;
|
||||||
private readonly timeout: number;
|
private readonly timeout: number;
|
||||||
private sweeper: NodeJS.Timeout | undefined;
|
private sweeper: NodeJS.Timeout | undefined;
|
||||||
|
private total = 0;
|
||||||
|
|
||||||
constructor(options: ExpiringGroupsOptions) {
|
constructor(options: ExpiringGroupsOptions) {
|
||||||
this.max = options.max;
|
this.max = options.max;
|
||||||
|
this.maxWeight = options.maxWeight ?? Infinity;
|
||||||
this.now = options.now ?? Date.now;
|
this.now = options.now ?? Date.now;
|
||||||
this.onSweep = options.onSweep;
|
this.onSweep = options.onSweep;
|
||||||
this.timeout = options.timeout;
|
this.timeout = options.timeout;
|
||||||
@@ -39,13 +42,18 @@ export class ExpiringGroups<T> {
|
|||||||
return this.entries.size;
|
return this.entries.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get weight(): number {
|
||||||
|
return this.total;
|
||||||
|
}
|
||||||
|
|
||||||
get(key: string): T | undefined {
|
get(key: string): T | undefined {
|
||||||
return this.entries.get(key)?.group;
|
return this.entries.get(key)?.group;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts the group's deadline, and the sweeper if this is the only group held. */
|
/** Replacing a key restarts its deadline and zeroes its weight; weigh() it again. */
|
||||||
set(key: string, group: T): void {
|
set(key: string, group: T): void {
|
||||||
this.entries.set(key, { deadline: this.now() + this.timeout, group });
|
this.remove(key);
|
||||||
|
this.entries.set(key, { deadline: this.now() + this.timeout, group, weight: 0 });
|
||||||
|
|
||||||
if (this.sweeper) return;
|
if (this.sweeper) return;
|
||||||
|
|
||||||
@@ -54,11 +62,31 @@ export class ExpiringGroups<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
delete(key: string): void {
|
delete(key: string): void {
|
||||||
this.entries.delete(key);
|
this.remove(key);
|
||||||
this.idle();
|
this.idle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes every group and hands them over, so an owner that must account for them can. */
|
/** The returned groups are already removed, and may include key itself. */
|
||||||
|
weigh(key: string, weight: number): [string, T][] {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
const taken: [string, T][] = [];
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
this.total += weight - entry.weight;
|
||||||
|
entry.weight = weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this.total > this.maxWeight) {
|
||||||
|
const oldest = this.takeOldest();
|
||||||
|
|
||||||
|
if (!oldest) break;
|
||||||
|
|
||||||
|
taken.push(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
|
||||||
takeAll(): [string, T][] {
|
takeAll(): [string, T][] {
|
||||||
const taken: [string, T][] = [];
|
const taken: [string, T][] = [];
|
||||||
|
|
||||||
@@ -67,12 +95,12 @@ export class ExpiringGroups<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.entries.clear();
|
this.entries.clear();
|
||||||
|
this.total = 0;
|
||||||
this.idle();
|
this.idle();
|
||||||
|
|
||||||
return taken;
|
return taken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes every group past its deadline and hands them over. */
|
|
||||||
takeExpired(): [string, T][] {
|
takeExpired(): [string, T][] {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
const taken: [string, T][] = [];
|
const taken: [string, T][] = [];
|
||||||
@@ -81,7 +109,7 @@ export class ExpiringGroups<T> {
|
|||||||
if (entry.deadline > now) continue;
|
if (entry.deadline > now) continue;
|
||||||
|
|
||||||
taken.push([key, entry.group]);
|
taken.push([key, entry.group]);
|
||||||
this.entries.delete(key);
|
this.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.idle();
|
this.idle();
|
||||||
@@ -89,7 +117,6 @@ export class ExpiringGroups<T> {
|
|||||||
return taken;
|
return taken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes the group held longest and hands it over. Undefined means there was none. */
|
|
||||||
takeOldest(): [string, T] | undefined {
|
takeOldest(): [string, T] | undefined {
|
||||||
const oldest = this.entries.entries().next();
|
const oldest = this.entries.entries().next();
|
||||||
|
|
||||||
@@ -102,6 +129,15 @@ export class ExpiringGroups<T> {
|
|||||||
return [key, entry.group];
|
return [key, entry.group];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private remove(key: string): void {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
this.entries.delete(key);
|
||||||
|
this.total -= entry.weight;
|
||||||
|
}
|
||||||
|
|
||||||
private idle(): void {
|
private idle(): void {
|
||||||
if (!this.sweeper || this.entries.size > 0) return;
|
if (!this.sweeper || this.entries.size > 0) return;
|
||||||
|
|
||||||
|
|||||||
+64
-23
@@ -2,10 +2,12 @@ import type { PduObject } from './pdu.ts';
|
|||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
import { ExpiringGroups } from './expiring-groups.ts';
|
import { ExpiringGroups } from './expiring-groups.ts';
|
||||||
import { IdleWaiters } from './idle-waiters.ts';
|
import { IdleWaiters } from './idle-waiters.ts';
|
||||||
|
import { retainedOctets } from './retained-pdu.ts';
|
||||||
|
|
||||||
export type HeldMessagesOptions = {
|
export type HeldMessagesOptions = {
|
||||||
log: SmppLog;
|
log: SmppLog;
|
||||||
max: number;
|
max: number;
|
||||||
|
maxOctets: number;
|
||||||
/** Injected so expiry can be exercised without a wall clock. */
|
/** Injected so expiry can be exercised without a wall clock. */
|
||||||
now?: (() => number) | undefined;
|
now?: (() => number) | undefined;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
@@ -18,12 +20,50 @@ function keyOf(pduObjs: PduObject[]): string | undefined {
|
|||||||
return first ? String(first.seqNr) : undefined;
|
return first ? String(first.seqNr) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HoldEntry = {
|
||||||
|
isHeld: () => boolean;
|
||||||
|
release: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One message offered to the application, held until it is answered or every listener gives up. */
|
||||||
|
export class MessageHold {
|
||||||
|
private readonly entry: HoldEntry;
|
||||||
|
private working: number;
|
||||||
|
|
||||||
|
constructor(entry: HoldEntry, listeners: number) {
|
||||||
|
this.entry = entry;
|
||||||
|
this.working = listeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a drain is still waiting for this message to be answered. */
|
||||||
|
isHeld(): boolean {
|
||||||
|
return this.entry.isHeld();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A turn later, so a listener sending its receipt straight after the response still holds. */
|
||||||
|
answered(): void {
|
||||||
|
setImmediate(() => { this.entry.release(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rejection leaves the other listeners running, so only the last one to fail gives the message up. */
|
||||||
|
listenerGaveUp(): void {
|
||||||
|
this.working--;
|
||||||
|
|
||||||
|
if (this.working <= 0) this.answered();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At once, for a message nobody took: that is not work a shutdown can wait for. */
|
||||||
|
release(): void {
|
||||||
|
this.entry.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The messages handed to the application that it has not answered yet, held by their segments. */
|
/** The messages handed to the application that it has not answered yet, held by their segments. */
|
||||||
export class HeldMessages {
|
export class HeldMessages {
|
||||||
private readonly held: ExpiringGroups<PduObject[]>;
|
private readonly held: ExpiringGroups<PduObject[]>;
|
||||||
private readonly idleWaiters = new IdleWaiters();
|
private readonly idleWaiters = new IdleWaiters();
|
||||||
private readonly log: SmppLog;
|
private readonly log: SmppLog;
|
||||||
private readonly max: number;
|
private readonly maxOctets: number;
|
||||||
|
|
||||||
constructor(options: HeldMessagesOptions) {
|
constructor(options: HeldMessagesOptions) {
|
||||||
this.held = new ExpiringGroups({
|
this.held = new ExpiringGroups({
|
||||||
@@ -33,38 +73,52 @@ export class HeldMessages {
|
|||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
});
|
});
|
||||||
this.log = options.log;
|
this.log = options.log;
|
||||||
this.max = options.max;
|
this.maxOctets = options.maxOctets;
|
||||||
|
}
|
||||||
|
|
||||||
|
get octetsHeld(): number {
|
||||||
|
return this.held.weight;
|
||||||
}
|
}
|
||||||
|
|
||||||
get size(): number {
|
get size(): number {
|
||||||
return this.held.size;
|
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. */
|
||||||
hold(pduObjs: PduObject[]): void {
|
full(): boolean {
|
||||||
|
this.sweep();
|
||||||
|
|
||||||
|
return this.held.full || this.held.weight >= this.maxOctets;
|
||||||
|
}
|
||||||
|
|
||||||
|
hold(pduObjs: PduObject[], listeners: number): MessageHold {
|
||||||
|
const hold = new MessageHold({
|
||||||
|
isHeld: () => this.has(pduObjs),
|
||||||
|
release: () => { this.release(pduObjs); },
|
||||||
|
}, listeners);
|
||||||
const key = keyOf(pduObjs);
|
const key = keyOf(pduObjs);
|
||||||
|
|
||||||
if (key === undefined) return;
|
if (key === undefined) return hold;
|
||||||
|
|
||||||
this.sweep();
|
this.sweep();
|
||||||
|
|
||||||
if (this.held.get(key)) {
|
if (this.held.get(key)) {
|
||||||
this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
|
this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
|
||||||
} else if (this.held.full) {
|
|
||||||
this.dropOldest();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.held.set(key, pduObjs);
|
this.held.set(key, pduObjs);
|
||||||
|
this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0));
|
||||||
|
|
||||||
|
return hold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a drain is still waiting for this message to be answered. */
|
private has(pduObjs: PduObject[]): boolean {
|
||||||
has(pduObjs: PduObject[]): boolean {
|
|
||||||
const key = keyOf(pduObjs);
|
const key = keyOf(pduObjs);
|
||||||
|
|
||||||
return key !== undefined && this.held.get(key) === pduObjs;
|
return key !== undefined && this.held.get(key) === pduObjs;
|
||||||
}
|
}
|
||||||
|
|
||||||
release(pduObjs: PduObject[]): void {
|
private release(pduObjs: PduObject[]): void {
|
||||||
const key = keyOf(pduObjs);
|
const key = keyOf(pduObjs);
|
||||||
|
|
||||||
// Identity, not the key: a wrapped sequence number must not release someone else's message.
|
// Identity, not the key: a wrapped sequence number must not release someone else's message.
|
||||||
@@ -85,19 +139,6 @@ export class HeldMessages {
|
|||||||
return this.idleWaiters.wait(() => this.held.size, timeout, signal);
|
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. */
|
/** Drops every message past its deadline. Runs before each hold and on its own timer. */
|
||||||
sweep(): void {
|
sweep(): void {
|
||||||
const expired = this.held.takeExpired();
|
const expired = this.held.takeExpired();
|
||||||
|
|||||||
+113
-55
@@ -1,23 +1,31 @@
|
|||||||
|
import type { BindType, LinkEnd } from './session-options.ts';
|
||||||
import type { Concat } from './concat.ts';
|
import type { Concat } from './concat.ts';
|
||||||
import type { DlrMerger } from './dlr-merger.ts';
|
import type { Dlr } from './dlr.ts';
|
||||||
|
import type { DlrMerger, MessageDlr } from './dlr-merger.ts';
|
||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { ErrorName } from './defs/errors.ts';
|
||||||
import type { LostGroup, Refusal } from './reassembly.ts';
|
import type { LostGroup, Refusal } from './reassembly.ts';
|
||||||
import type { OnRequest } from './session-options.ts';
|
import type { ParamValue } from './defs/types.ts';
|
||||||
import type { PduObject, PduObjectInput } from './pdu.ts';
|
import type { PduObject, PduObjectInput } from './pdu.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
import type { Session } from './session.ts';
|
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
|
import type { Sms, SmsHandlers, SmsInput } from './sms.ts';
|
||||||
import type { SmsIdFormat } from './sms-id.ts';
|
import type { SmsIdFormat } from './sms-id.ts';
|
||||||
|
import type { MessageHold } from './held-messages.ts';
|
||||||
import { HeldMessages } from './held-messages.ts';
|
import { HeldMessages } from './held-messages.ts';
|
||||||
import { Reassembler, decodeSegments } from './reassembly.ts';
|
import { Reassembler, decodeSegments } from './reassembly.ts';
|
||||||
import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
||||||
import { concatOf } from './concat.ts';
|
import { concatOf } from './concat.ts';
|
||||||
import { createSms } from './sms.ts';
|
import { detach } from './retained-pdu.ts';
|
||||||
import { dlrFromPdu } from './dlr.ts';
|
import { dlrFromPdu } from './dlr.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { paramText } from './defs/types.ts';
|
||||||
import { respIdParams, segmentId } from './sms-id.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(
|
export function refusedSegmentStatus(
|
||||||
carriedAs: string,
|
carriedAs: string,
|
||||||
refusal: Refusal,
|
refusal: Refusal,
|
||||||
@@ -28,7 +36,7 @@ export function refusedSegmentStatus(
|
|||||||
return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS';
|
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> = {
|
const lostReasons: Record<LostGroup['reason'], string> = {
|
||||||
@@ -37,44 +45,65 @@ const lostReasons: Record<LostGroup['reason'], string> = {
|
|||||||
linkGone: 'the link they arrived on went',
|
linkGone: 'the link they arrived on went',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Send = (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||||
|
|
||||||
|
/** What the incoming side asks of the session it serves; the session decides how. */
|
||||||
|
export type IncomingDeps = {
|
||||||
|
acceptsOptionalParams: () => boolean;
|
||||||
|
answer: (pduObj: PduObject, status?: ErrorName, params?: Record<string, ParamValue>) => Promise<VoidResult>;
|
||||||
|
bindAllows: (cmdName: string) => boolean;
|
||||||
|
boundAs: () => BindType | undefined;
|
||||||
|
createSms: (input: Omit<SmsInput, 'session'>, handlers: SmsHandlers) => Sms;
|
||||||
|
linkEnd: () => LinkEnd;
|
||||||
|
/** Answers whether any listener took the message. */
|
||||||
|
offerSms: (sms: Sms) => boolean;
|
||||||
|
/** The application's first refusal, answering true where it took the request itself. */
|
||||||
|
onRequest?: ((pduObj: PduObject) => Promise<boolean> | boolean) | undefined;
|
||||||
|
peerUnbound: () => Promise<VoidResult>;
|
||||||
|
reportDlr: (dlr: Dlr, pduObj: PduObject) => void;
|
||||||
|
reportError: (err: Error) => void;
|
||||||
|
reportMessageDlr: (merged: MessageDlr) => void;
|
||||||
|
send: Send;
|
||||||
|
/** Past a drain's refusal, for a receipt the drain is itself waiting for. */
|
||||||
|
sendPastDrain: Send;
|
||||||
|
smsListeners: () => number;
|
||||||
|
};
|
||||||
|
|
||||||
export type IncomingRequestsOptions = {
|
export type IncomingRequestsOptions = {
|
||||||
|
deps: IncomingDeps;
|
||||||
dlrMerger: DlrMerger;
|
dlrMerger: DlrMerger;
|
||||||
log: SmppLog;
|
log: SmppLog;
|
||||||
maxOctets?: number | undefined;
|
maxOctets?: number | undefined;
|
||||||
maxReassembly?: number | undefined;
|
maxReassembly?: number | undefined;
|
||||||
onRequest?: OnRequest | undefined;
|
|
||||||
reassemblyTimeout?: number | undefined;
|
reassemblyTimeout?: number | undefined;
|
||||||
/** Past a drain's refusal, for a receipt the drain is itself waiting for. */
|
|
||||||
sendPastDrain: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
|
||||||
session: Session;
|
|
||||||
smsIdFormat?: SmsIdFormat | undefined;
|
smsIdFormat?: SmsIdFormat | undefined;
|
||||||
systemId?: string | undefined;
|
systemId?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
|
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
|
||||||
export class IncomingRequests {
|
export class IncomingRequests {
|
||||||
|
private readonly deps: IncomingDeps;
|
||||||
private readonly dlrMerger: DlrMerger;
|
private readonly dlrMerger: DlrMerger;
|
||||||
/** The rejection handler is handed the Sms back as an `unknown`, so its hold is found by identity. */
|
|
||||||
private readonly emitted = new WeakMap<object, () => void>();
|
|
||||||
private readonly held: HeldMessages;
|
private readonly held: HeldMessages;
|
||||||
|
/** The rejection handler is handed the Sms back as an `unknown`, so its hold is found by identity. */
|
||||||
|
private readonly holds = new WeakMap<object, MessageHold>();
|
||||||
private readonly log: SmppLog;
|
private readonly log: SmppLog;
|
||||||
private readonly onRequest: OnRequest | undefined;
|
|
||||||
private readonly reassembler: Reassembler;
|
private readonly reassembler: Reassembler;
|
||||||
private readonly sendPastDrain: IncomingRequestsOptions['sendPastDrain'];
|
|
||||||
private readonly session: Session;
|
|
||||||
private readonly smsIdFormat: SmsIdFormat;
|
private readonly smsIdFormat: SmsIdFormat;
|
||||||
private readonly systemId: string;
|
private readonly systemId: string;
|
||||||
private linkGeneration = 0;
|
private linkGeneration = 0;
|
||||||
|
private refusing = false;
|
||||||
|
|
||||||
constructor(options: IncomingRequestsOptions) {
|
constructor(options: IncomingRequestsOptions) {
|
||||||
|
this.deps = options.deps;
|
||||||
this.dlrMerger = options.dlrMerger;
|
this.dlrMerger = options.dlrMerger;
|
||||||
this.held = new HeldMessages({
|
this.held = new HeldMessages({
|
||||||
log: options.log,
|
log: options.log,
|
||||||
max: defaults.maxHeldMessages,
|
max: defaults.maxHeldMessages,
|
||||||
|
maxOctets: defaults.maxHeldOctets,
|
||||||
timeout: defaults.heldMessageTimeout,
|
timeout: defaults.heldMessageTimeout,
|
||||||
});
|
});
|
||||||
this.log = options.log;
|
this.log = options.log;
|
||||||
this.onRequest = options.onRequest;
|
|
||||||
this.reassembler = new Reassembler({
|
this.reassembler = new Reassembler({
|
||||||
log: options.log,
|
log: options.log,
|
||||||
max: options.maxReassembly ?? defaults.maxReassembly,
|
max: options.maxReassembly ?? defaults.maxReassembly,
|
||||||
@@ -82,8 +111,6 @@ export class IncomingRequests {
|
|||||||
onLost: lost => { this.reportLost(lost); },
|
onLost: lost => { this.reportLost(lost); },
|
||||||
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
|
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
|
||||||
});
|
});
|
||||||
this.sendPastDrain = options.sendPastDrain;
|
|
||||||
this.session = options.session;
|
|
||||||
this.smsIdFormat = options.smsIdFormat ?? {};
|
this.smsIdFormat = options.smsIdFormat ?? {};
|
||||||
this.systemId = options.systemId ?? defaults.systemId;
|
this.systemId = options.systemId ?? defaults.systemId;
|
||||||
}
|
}
|
||||||
@@ -91,7 +118,7 @@ export class IncomingRequests {
|
|||||||
async handle(pduObj: PduObject): Promise<void> {
|
async handle(pduObj: PduObject): Promise<void> {
|
||||||
const generation = this.linkGeneration;
|
const generation = this.linkGeneration;
|
||||||
|
|
||||||
if (this.onRequest && await this.onRequest(this.session, pduObj)) return;
|
if (this.deps.onRequest && await this.deps.onRequest(pduObj)) return;
|
||||||
|
|
||||||
// The link it arrived on went while the hook ran, so nothing we answer now correlates.
|
// The link it arrived on went while the hook ran, so nothing we answer now correlates.
|
||||||
if (this.linkGeneration !== generation) {
|
if (this.linkGeneration !== generation) {
|
||||||
@@ -100,12 +127,12 @@ export class IncomingRequests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.session.bindAllows(pduObj.cmdName)) {
|
if (!this.deps.bindAllows(pduObj.cmdName)) {
|
||||||
this.log.info('session - command the peer\'s bind direction does not carry', {
|
this.log.info('session - command the peer\'s bind direction does not carry', {
|
||||||
bindType: this.session.boundAs ?? '',
|
bindType: this.deps.boundAs() ?? '',
|
||||||
cmdName: pduObj.cmdName,
|
cmdName: pduObj.cmdName,
|
||||||
});
|
});
|
||||||
await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
await this.deps.answer(pduObj, 'ESME_RINVBNDSTS');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,15 +150,14 @@ export class IncomingRequests {
|
|||||||
: this.onDelivery(pduObj));
|
: this.onDelivery(pduObj));
|
||||||
break;
|
break;
|
||||||
case 'enquire_link':
|
case 'enquire_link':
|
||||||
await this.session.sendReturn(pduObj);
|
await this.deps.answer(pduObj);
|
||||||
break;
|
break;
|
||||||
case 'submit_sm':
|
case 'submit_sm':
|
||||||
await this.onMessage(pduObj);
|
await this.onMessage(pduObj);
|
||||||
break;
|
break;
|
||||||
case 'unbind':
|
case 'unbind':
|
||||||
await this.session.sendReturn(pduObj);
|
await this.deps.answer(pduObj);
|
||||||
// A peer that has said it is finished will not answer what we still have outstanding.
|
await this.deps.peerUnbound();
|
||||||
await this.session.close({ signal: AbortSignal.abort() });
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
await this.unhandled(pduObj);
|
await this.unhandled(pduObj);
|
||||||
@@ -141,6 +167,7 @@ export class IncomingRequests {
|
|||||||
/** Drops the segments of every message that never became whole, and of every one still held. */
|
/** Drops the segments of every message that never became whole, and of every one still held. */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.linkGeneration++;
|
this.linkGeneration++;
|
||||||
|
this.refusing = false;
|
||||||
this.held.clear();
|
this.held.clear();
|
||||||
this.reassembler.clear();
|
this.reassembler.clear();
|
||||||
}
|
}
|
||||||
@@ -149,7 +176,7 @@ export class IncomingRequests {
|
|||||||
listenerRejected(sms: unknown): void {
|
listenerRejected(sms: unknown): void {
|
||||||
if (typeof sms !== 'object' || sms === null) return;
|
if (typeof sms !== 'object' || sms === null) return;
|
||||||
|
|
||||||
this.emitted.get(sms)?.();
|
this.holds.get(sms)?.listenerGaveUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Waits out the messages the application still holds, and says how many it never answered. */
|
/** Waits out the messages the application still holds, and says how many it never answered. */
|
||||||
@@ -166,17 +193,23 @@ export class IncomingRequests {
|
|||||||
private async unhandled(pduObj: PduObject): Promise<void> {
|
private async unhandled(pduObj: PduObject): Promise<void> {
|
||||||
if (bindCommands.includes(pduObj.cmdName)) {
|
if (bindCommands.includes(pduObj.cmdName)) {
|
||||||
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
|
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
|
||||||
await this.session.sendReturn(pduObj, 'ESME_RALYBND', { system_id: this.systemId });
|
await this.deps.answer(pduObj, 'ESME_RALYBND', { system_id: this.systemId });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!respNameFor(pduObj.cmdName)) {
|
||||||
|
this.log.verbose('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
|
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
|
||||||
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
|
await this.deps.answer(pduObj, 'ESME_RINVCMDID');
|
||||||
}
|
}
|
||||||
|
|
||||||
private carriedAs(pduObj: PduObject): string {
|
private carriedAs(pduObj: PduObject): string {
|
||||||
return standsInFor(pduObj.cmdName, this.session.linkEnd);
|
return standsInFor(pduObj.cmdName, this.deps.linkEnd());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
|
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
|
||||||
@@ -189,13 +222,45 @@ export class IncomingRequests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.session.emit('dlr', dlr, pduObj);
|
this.deps.reportDlr(dlr, pduObj);
|
||||||
|
|
||||||
const merged = this.dlrMerger.collect(dlr);
|
const merged = this.dlrMerger.collect(dlr);
|
||||||
|
|
||||||
if (merged) this.session.emit('messageDlr', merged);
|
if (merged) this.deps.reportMessageDlr(merged);
|
||||||
|
|
||||||
await this.session.sendReturn(pduObj);
|
await this.deps.answer(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.deps.answer(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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,10 +268,12 @@ export class IncomingRequests {
|
|||||||
* one request at a time never sends the second segment until the first has been answered.
|
* one request at a time never sends the second segment until the first has been answered.
|
||||||
*/
|
*/
|
||||||
private async onMessage(pduObj: PduObject): Promise<void> {
|
private async onMessage(pduObj: PduObject): Promise<void> {
|
||||||
|
if (await this.refusedAtBound(pduObj)) return;
|
||||||
|
|
||||||
const concat = concatOf(pduObj);
|
const concat = concatOf(pduObj);
|
||||||
|
|
||||||
if (!concat) {
|
if (!concat) {
|
||||||
this.emitSms([pduObj]);
|
this.emitSms([detach(pduObj)]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -214,7 +281,7 @@ export class IncomingRequests {
|
|||||||
const collected = this.reassembler.collect(pduObj, concat);
|
const collected = this.reassembler.collect(pduObj, concat);
|
||||||
|
|
||||||
if (!collected.kept) {
|
if (!collected.kept) {
|
||||||
await this.session.sendReturn(
|
await this.deps.answer(
|
||||||
pduObj,
|
pduObj,
|
||||||
refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling),
|
refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling),
|
||||||
);
|
);
|
||||||
@@ -222,7 +289,7 @@ export class IncomingRequests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.session.sendReturn(
|
await this.deps.answer(
|
||||||
pduObj,
|
pduObj,
|
||||||
'ESME_ROK',
|
'ESME_ROK',
|
||||||
respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)),
|
respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)),
|
||||||
@@ -232,7 +299,7 @@ export class IncomingRequests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private reportLost(lost: LostGroup): void {
|
private reportLost(lost: LostGroup): void {
|
||||||
this.session.emit('sessionError', new Error(
|
this.deps.reportError(new Error(
|
||||||
`Gave up ${String(lost.parts)} of ${String(lost.total)} segments of an incomplete concatenated message: ${lostReasons[lost.reason]}`,
|
`Gave up ${String(lost.parts)} of ${String(lost.total)} segments of an incomplete concatenated message: ${lostReasons[lost.reason]}`,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -243,34 +310,25 @@ export class IncomingRequests {
|
|||||||
if (!first) return;
|
if (!first) return;
|
||||||
|
|
||||||
const generation = this.linkGeneration;
|
const generation = this.linkGeneration;
|
||||||
// A turn later, so a listener sending its receipt straight after the response still holds.
|
const hold = this.held.hold(pduObjs, this.deps.smsListeners());
|
||||||
const release = (): void => { setImmediate(() => { this.held.release(pduObjs); }); };
|
|
||||||
|
|
||||||
const sms = createSms({
|
const sms = this.deps.createSms({
|
||||||
answeredAs,
|
answeredAs,
|
||||||
from: paramText(first.params.source_addr),
|
from: paramText(first.params.source_addr),
|
||||||
message: decodeSegments(pduObjs),
|
message: decodeSegments(pduObjs),
|
||||||
pduObjs,
|
pduObjs,
|
||||||
session: this.session,
|
|
||||||
to: paramText(first.params.destination_addr),
|
to: paramText(first.params.destination_addr),
|
||||||
}, {
|
}, {
|
||||||
|
acceptsOptionalParams: () => this.deps.acceptsOptionalParams(),
|
||||||
|
answer: (pduObj, status, params) => this.deps.answer(pduObj, status, params),
|
||||||
|
bindAllows: cmdName => this.deps.bindAllows(cmdName),
|
||||||
lostLink: () => this.linkGeneration !== generation,
|
lostLink: () => this.linkGeneration !== generation,
|
||||||
onAnswered: release,
|
onAnswered: () => { hold.answered(); },
|
||||||
// Past the refusal only while a drain is still waiting for this message; an ordinary send after.
|
send: input => (hold.isHeld() ? this.deps.sendPastDrain(input) : this.deps.send(input)),
|
||||||
send: input => (this.held.has(pduObjs) ? this.sendPastDrain(input) : this.session.send(input)),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// A rejection leaves the other listeners running, so only the last one to fail gives the message up.
|
this.holds.set(sms, hold);
|
||||||
let working = this.session.listenerCount('sms');
|
|
||||||
|
|
||||||
this.held.hold(pduObjs);
|
if (!this.deps.offerSms(sms)) hold.release();
|
||||||
this.emitted.set(sms, () => {
|
|
||||||
working--;
|
|
||||||
|
|
||||||
if (working <= 0) release();
|
|
||||||
});
|
|
||||||
|
|
||||||
// A message nobody took is not work a shutdown can wait for.
|
|
||||||
if (!this.session.emit('sms', sms)) this.held.release(pduObjs);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -6,7 +6,7 @@ export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.
|
|||||||
export { consts, constsById } from './defs/constants.ts';
|
export { consts, constsById } from './defs/constants.ts';
|
||||||
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
|
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
|
||||||
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
export { 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 { types } from './defs/types.ts';
|
||||||
|
|
||||||
export {
|
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 { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
|
||||||
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
|
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
|
||||||
export type { ErrorName } from './defs/errors.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 { PduHeader } from './pdu-refusal.ts';
|
||||||
export type { SplitOptions } from './message.ts';
|
export type { SplitOptions } from './message.ts';
|
||||||
export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts';
|
export type { Tlv, TlvDefinition, TlvName, Tlvs } from './defs/tlvs.ts';
|
||||||
export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.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. */
|
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
|
||||||
export { defs } from './defs/index.ts';
|
export { defs } from './defs/index.ts';
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export class LinkGate {
|
|||||||
return this.up;
|
return this.up;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shut, with another link on its way to reopen it. */
|
||||||
|
awaitsNextLink(): boolean {
|
||||||
|
return !this.up && this.returning;
|
||||||
|
}
|
||||||
|
|
||||||
/** Why the gate will never admit a request, or undefined while one may still get through. */
|
/** Why the gate will never admit a request, or undefined while one may still get through. */
|
||||||
refusal(): Error | undefined {
|
refusal(): Error | undefined {
|
||||||
return this.up || this.returning ? undefined : over();
|
return this.up || this.returning ? undefined : over();
|
||||||
|
|||||||
+25
-13
@@ -51,9 +51,13 @@ export class OutgoingRequests {
|
|||||||
this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log });
|
this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read through a method: a drop can land while a request is awaiting. */
|
canCarry(): boolean {
|
||||||
linkDown(): boolean {
|
return this.gate.isUp() && !this.transport.sock.destroyed;
|
||||||
return !this.gate.isUp() || this.transport.sock.destroyed;
|
}
|
||||||
|
|
||||||
|
/** The link went before the drain finished, so an empty window says nothing about the peer. */
|
||||||
|
droppedWhileDraining(): boolean {
|
||||||
|
return this.draining && !this.canCarry();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A link is up and bound, so everything held for one goes out on it. */
|
/** A link is up and bound, so everything held for one goes out on it. */
|
||||||
@@ -84,16 +88,16 @@ export class OutgoingRequests {
|
|||||||
|
|
||||||
if (wrong) return Promise.resolve({ err: wrong });
|
if (wrong) return Promise.resolve({ err: wrong });
|
||||||
|
|
||||||
// A drain on a live link. A link that is down is the gate's answer, which says closed instead.
|
// With no link, the request is refused as closed further on.
|
||||||
if (this.draining && !this.linkDown()) {
|
if (this.draining && this.canCarry()) {
|
||||||
return Promise.resolve({ err: new Error('Session is shutting down') });
|
return Promise.resolve({ err: new Error('Session is shutting down') });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.pastDrain(input, options);
|
return this.requestPastDrain(input, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The same path without that refusal, which a receipt for a held message has to take. */
|
/** request() without the drain's refusal, which a receipt for a held message has to take. */
|
||||||
async pastDrain(
|
async requestPastDrain(
|
||||||
input: PduObjectInput,
|
input: PduObjectInput,
|
||||||
options: SendOptions,
|
options: SendOptions,
|
||||||
): Promise<Result<{ pduObj: PduObject }>> {
|
): Promise<Result<{ pduObj: PduObject }>> {
|
||||||
@@ -105,7 +109,7 @@ export class OutgoingRequests {
|
|||||||
if (bindCommands.includes(input.cmdName)) {
|
if (bindCommands.includes(input.cmdName)) {
|
||||||
const shut = this.gate.refusal();
|
const shut = this.gate.refusal();
|
||||||
|
|
||||||
return shut ? { err: shut } : this.now(input, options);
|
return shut ? { err: shut } : this.requestPastDrainGateAndWindow(input, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
const waitForLink = this.gate.hold(options.signal);
|
const waitForLink = this.gate.hold(options.signal);
|
||||||
@@ -121,13 +125,15 @@ export class OutgoingRequests {
|
|||||||
|
|
||||||
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
|
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
|
||||||
|
|
||||||
// Nothing reached the socket, so the next link carries it instead of the caller resending.
|
if (!this.retriesOnNextLink(attempt)) return attempt.result;
|
||||||
if (!attempt.retryOnNextLink || this.gate.isUp() || this.gate.refusal()) return attempt.result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Past the gate, the window and a drain, for what has to go out either way. */
|
/** Straight onto the current link, for what has to go out either way. */
|
||||||
async now(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> {
|
async requestPastDrainGateAndWindow(
|
||||||
|
input: PduObjectInput,
|
||||||
|
options: SendOptions = {},
|
||||||
|
): Promise<Result<{ pduObj: PduObject }>> {
|
||||||
return (await this.attempt(input, options)).result;
|
return (await this.attempt(input, options)).result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +153,12 @@ export class OutgoingRequests {
|
|||||||
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
|
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nothing reached the socket, so the next link carries it instead of the caller resending. */
|
||||||
|
private retriesOnNextLink(attempt: Attempt): boolean {
|
||||||
|
// Until the gate is shut it admits the retry straight back onto the dead socket, and the loop spins.
|
||||||
|
return attempt.retryOnNextLink && this.gate.awaitsNextLink();
|
||||||
|
}
|
||||||
|
|
||||||
/** Why a request cannot go out at all, as opposed to not yet. */
|
/** Why a request cannot go out at all, as opposed to not yet. */
|
||||||
private refuse(input: PduObjectInput, options: SendOptions): Error | undefined {
|
private refuse(input: PduObjectInput, options: SendOptions): Error | undefined {
|
||||||
// Before the gate and the window, or an aborted call waits for what it will never use.
|
// Before the gate and the window, or an aborted call waits for what it will never use.
|
||||||
|
|||||||
+51
-105
@@ -3,14 +3,14 @@ import type { ErrorName } from './defs/errors.ts';
|
|||||||
import type { ParamValue } from './defs/types.ts';
|
import type { ParamValue } from './defs/types.ts';
|
||||||
import type { PduHeader } from './pdu-refusal.ts';
|
import type { PduHeader } from './pdu-refusal.ts';
|
||||||
import type { Result, VoidResult } from './result.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 { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
|
||||||
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
|
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
|
||||||
import { hasUdh } from './defs/constants.ts';
|
import { hasUdh } from './defs/constants.ts';
|
||||||
import { decodeMessage, encodeBody } from './message.ts';
|
import { decodeMessage, encodeBody } from './message.ts';
|
||||||
import { errorNameById, errors, isErrorName } from './defs/errors.ts';
|
import { errorNameById, errors, isErrorName } from './defs/errors.ts';
|
||||||
import { paramNumber } from './defs/types.ts';
|
import { paramNumber, valueText } from './defs/types.ts';
|
||||||
import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts';
|
import { parseTlvs, writeTlvs } from './defs/tlvs.ts';
|
||||||
|
|
||||||
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
|
/** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */
|
||||||
export const maxSeqNr = 2147483646;
|
export const maxSeqNr = 2147483646;
|
||||||
@@ -23,7 +23,7 @@ export type PduObjectInput<C extends CommandName = CommandName> = {
|
|||||||
cmdStatus?: ErrorName;
|
cmdStatus?: ErrorName;
|
||||||
params?: PduParamsInput<C>;
|
params?: PduParamsInput<C>;
|
||||||
seqNr?: number;
|
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.
|
* the peer put in `message_payload` is not here; `messageOctets()` is what reads either.
|
||||||
*/
|
*/
|
||||||
shortMessageOctets: Buffer | undefined;
|
shortMessageOctets: Buffer | undefined;
|
||||||
tlvs: Record<string, Tlv>;
|
tlvs: Tlvs;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { TlvInput };
|
export type { TlvInputs };
|
||||||
|
|
||||||
const respBit = 0x80000000;
|
const respBit = 0x80000000;
|
||||||
|
|
||||||
@@ -68,96 +68,71 @@ export function isCommand<C extends CommandName>(
|
|||||||
|
|
||||||
type ResolvedBody = {
|
type ResolvedBody = {
|
||||||
params: Record<string, ParamValue | undefined>;
|
params: Record<string, ParamValue | undefined>;
|
||||||
tlvs: Record<string, TlvInput> | undefined;
|
tlvs: TlvInputs | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the PDU's data_coding describes, and so what may set it: short_message wherever it holds an
|
||||||
|
* octet, since messageOctets() reads it there, and message_payload only where it does not.
|
||||||
|
*/
|
||||||
|
type CodingSource = 'message_payload' | 'short_message';
|
||||||
|
|
||||||
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
|
function codingOf(params: Record<string, ParamValue | undefined>): number | undefined {
|
||||||
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
|
return typeof params.data_coding === 'number' ? params.data_coding : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CarriedBody = { name: string; text: string; tlv: TlvInput };
|
function resolveShortMessage(
|
||||||
|
params: Record<string, ParamValue | undefined>,
|
||||||
|
definition: CommandDefinition,
|
||||||
|
): Result<{ params: Record<string, ParamValue | undefined>; source: CodingSource }> {
|
||||||
|
// Only the short_message the command's own table will write, since writeParams() ignores any other.
|
||||||
|
const message = definition.params?.short_message === undefined ? undefined : params.short_message;
|
||||||
|
|
||||||
/** Every entry carrying body text, under whatever names their tagIds are keyed to. */
|
if (Buffer.isBuffer(message)) {
|
||||||
function carriedBodies(input: Record<string, TlvInput> | undefined): CarriedBody[] {
|
return {
|
||||||
const carried: CarriedBody[] = [];
|
params: params.sm_length === undefined ? { ...params, sm_length: message.length } : params,
|
||||||
|
source: message.length > 0 ? 'short_message' : 'message_payload',
|
||||||
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;
|
if (typeof message !== 'string') return { params, source: 'message_payload' };
|
||||||
}
|
|
||||||
|
|
||||||
/** messageOctets() reads short_message wherever it holds an octet, and the TLV only where it does not. */
|
const encoded = encodeBody(message, codingOf(params));
|
||||||
function carriesOctets(value: ParamValue | undefined): boolean {
|
|
||||||
return Buffer.isBuffer(value) && value.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The short_message the command's own table will write, since writeParams() ignores any other. */
|
if (encoded.err) {
|
||||||
function writtenBody(definition: CommandDefinition, value: ParamValue | undefined): ParamValue | undefined {
|
return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) };
|
||||||
return definition.params?.short_message === undefined ? undefined : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Encoded in place, settling data_coding where `settles` says no mandatory field will carry it. */
|
|
||||||
function resolveCarried(
|
|
||||||
resolved: ResolvedBody,
|
|
||||||
inputs: Record<string, TlvInput> | undefined,
|
|
||||||
dataCoding: number | undefined,
|
|
||||||
settles: boolean,
|
|
||||||
): VoidResult {
|
|
||||||
for (const carried of carriedBodies(inputs)) {
|
|
||||||
const encoded = encodeBody(carried.text, dataCoding);
|
|
||||||
|
|
||||||
if (encoded.err) return { err: new Error(`TLV "${carried.name}": ${encoded.err.message}`) };
|
|
||||||
|
|
||||||
if (settles) resolved.params.data_coding = encoded.dataCoding;
|
|
||||||
|
|
||||||
resolved.tlvs = { ...resolved.tlvs, [carried.name]: { ...carried.tlv, tagValue: encoded.buffer } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
const written = { ...params, short_message: encoded.buffer, sm_length: encoded.buffer.length };
|
||||||
|
|
||||||
|
if (encoded.buffer.length === 0) return { params: written, source: 'message_payload' };
|
||||||
|
|
||||||
|
return { params: { ...written, data_coding: encoded.dataCoding }, source: 'short_message' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** data_coding names the alphabet of the body, and short_message settles it where it carries octets. */
|
|
||||||
function resolveBody(
|
function resolveBody(
|
||||||
params: Record<string, ParamValue | undefined>,
|
params: Record<string, ParamValue | undefined>,
|
||||||
tlvs: Record<string, TlvInput> | undefined,
|
tlvs: TlvInputs | undefined,
|
||||||
definition: CommandDefinition,
|
definition: CommandDefinition,
|
||||||
): Result<ResolvedBody> {
|
): Result<ResolvedBody> {
|
||||||
const message = writtenBody(definition, params.short_message);
|
const shortMessage = resolveShortMessage(params, definition);
|
||||||
const resolved: ResolvedBody = { params: { ...params }, tlvs };
|
|
||||||
|
|
||||||
if (Buffer.isBuffer(message) && params.sm_length === undefined) {
|
if (shortMessage.err) return { err: shortMessage.err };
|
||||||
resolved.params.sm_length = message.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof message === 'string') {
|
const text = tlvs?.message_payload?.tagValue;
|
||||||
const encoded = encodeBody(message, codingOf(params));
|
|
||||||
|
|
||||||
if (encoded.err) {
|
if (typeof text !== 'string') return { params: shortMessage.params, tlvs };
|
||||||
return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (carriesOctets(encoded.buffer)) resolved.params.data_coding = encoded.dataCoding;
|
const encoded = encodeBody(text, codingOf(shortMessage.params));
|
||||||
|
|
||||||
resolved.params.short_message = encoded.buffer;
|
if (encoded.err) return { err: new Error(`TLV "message_payload": ${encoded.err.message}`) };
|
||||||
resolved.params.sm_length = encoded.buffer.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only octets the command's own table will write can settle the alphabet the PDU declares.
|
return {
|
||||||
const settles = !carriesOctets(writtenBody(definition, resolved.params.short_message));
|
params: shortMessage.source === 'message_payload'
|
||||||
const carried = resolveCarried(
|
? { ...shortMessage.params, data_coding: encoded.dataCoding }
|
||||||
resolved,
|
: shortMessage.params,
|
||||||
tlvs,
|
tlvs: { ...tlvs, message_payload: { tagValue: encoded.buffer } },
|
||||||
settles ? codingOf(params) : codingOf(resolved.params),
|
};
|
||||||
settles,
|
|
||||||
);
|
|
||||||
|
|
||||||
return carried.err ? { err: carried.err } : resolved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeParams(
|
function writeParams(
|
||||||
@@ -197,7 +172,7 @@ function buildBody(
|
|||||||
cmdName: CommandName,
|
cmdName: CommandName,
|
||||||
cmdStatus: ErrorName,
|
cmdStatus: ErrorName,
|
||||||
params: Record<string, ParamValue | undefined>,
|
params: Record<string, ParamValue | undefined>,
|
||||||
tlvs: Record<string, TlvInput> | undefined,
|
tlvs: TlvInputs | undefined,
|
||||||
): Result<{ body: Buffer }> {
|
): Result<{ body: Buffer }> {
|
||||||
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
|
if (errors[cmdStatus] !== 0 && definition.id >= respBit) return { body: Buffer.alloc(0) };
|
||||||
|
|
||||||
@@ -221,7 +196,7 @@ function buildPdu(
|
|||||||
cmdStatus: ErrorName,
|
cmdStatus: ErrorName,
|
||||||
seqNr: number,
|
seqNr: number,
|
||||||
params: Record<string, ParamValue | undefined>,
|
params: Record<string, ParamValue | undefined>,
|
||||||
tlvs: Record<string, TlvInput> | undefined,
|
tlvs: TlvInputs | undefined,
|
||||||
): Result<{ buffer: Buffer }> {
|
): Result<{ buffer: Buffer }> {
|
||||||
const definition = cmds[cmdName];
|
const definition = cmds[cmdName];
|
||||||
|
|
||||||
@@ -234,7 +209,7 @@ function buildPdu(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxWireSeqNr) {
|
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);
|
const built = buildBody(definition, cmdName, cmdStatus, params, tlvs);
|
||||||
@@ -262,35 +237,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(
|
function readParams(
|
||||||
cmdName: CommandName,
|
cmdName: CommandName,
|
||||||
pdu: Buffer,
|
pdu: Buffer,
|
||||||
@@ -322,7 +268,7 @@ function readOptionalParams(
|
|||||||
pdu: Buffer,
|
pdu: Buffer,
|
||||||
start: number,
|
start: number,
|
||||||
afterShortMessage: boolean,
|
afterShortMessage: boolean,
|
||||||
): Result<{ tlvs: Record<string, Tlv> }> {
|
): Result<{ tlvs: Tlvs }> {
|
||||||
const plain = parseTlvs(pdu, start);
|
const plain = parseTlvs(pdu, start);
|
||||||
|
|
||||||
if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs };
|
if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs };
|
||||||
@@ -447,7 +393,7 @@ export function pduReturn(
|
|||||||
pdu: Buffer | PduObject,
|
pdu: Buffer | PduObject,
|
||||||
status: ErrorName = 'ESME_ROK',
|
status: ErrorName = 'ESME_ROK',
|
||||||
params: Record<string, ParamValue> = {},
|
params: Record<string, ParamValue> = {},
|
||||||
tlvs?: Record<string, TlvInput>,
|
tlvs?: TlvInputs,
|
||||||
): Result<{ buffer: Buffer }> {
|
): Result<{ buffer: Buffer }> {
|
||||||
if (Buffer.isBuffer(pdu)) {
|
if (Buffer.isBuffer(pdu)) {
|
||||||
const parsed = pduToObj(pdu);
|
const parsed = pduToObj(pdu);
|
||||||
|
|||||||
+23
-80
@@ -1,10 +1,9 @@
|
|||||||
import type { Concat } from './concat.ts';
|
import type { Concat } from './concat.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
|
||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from './pdu.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
import type { Tlv } from './defs/tlvs.ts';
|
|
||||||
import { ExpiringGroups } from './expiring-groups.ts';
|
import { ExpiringGroups } from './expiring-groups.ts';
|
||||||
import { decodeMessage } from './message.ts';
|
import { decodeMessage } from './message.ts';
|
||||||
|
import { detach, retainedOctets } from './retained-pdu.ts';
|
||||||
import { messageOctets } from './message-body.ts';
|
import { messageOctets } from './message-body.ts';
|
||||||
import { paramNumber, paramText } from './defs/types.ts';
|
import { paramNumber, paramText } from './defs/types.ts';
|
||||||
import { uuidv7 } from './uuid.ts';
|
import { uuidv7 } from './uuid.ts';
|
||||||
@@ -43,59 +42,14 @@ export type Collected =
|
|||||||
whole?: PduObject[] | undefined;
|
whole?: PduObject[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultMaxOctets = 64 * 1024 * 1024;
|
export const defaultMaxOctets = 64 * 1024 * 1024;
|
||||||
|
|
||||||
type Group = {
|
type Group = {
|
||||||
octets: number;
|
|
||||||
parts: Map<number, PduObject>;
|
parts: Map<number, PduObject>;
|
||||||
smsId: string;
|
smsId: string;
|
||||||
total: number;
|
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.
|
// 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 {
|
function groupKey(pduObj: PduObject, concat: Concat): string {
|
||||||
return [
|
return [
|
||||||
@@ -133,18 +87,18 @@ export class Reassembler {
|
|||||||
private readonly maxOctets: number;
|
private readonly maxOctets: number;
|
||||||
private readonly newId: () => string;
|
private readonly newId: () => string;
|
||||||
private readonly onLost: (lost: LostGroup) => void;
|
private readonly onLost: (lost: LostGroup) => void;
|
||||||
private octets = 0;
|
|
||||||
|
|
||||||
constructor(options: ReassemblerOptions) {
|
constructor(options: ReassemblerOptions) {
|
||||||
|
this.maxOctets = options.maxOctets ?? defaultMaxOctets;
|
||||||
this.groups = new ExpiringGroups<Group>({
|
this.groups = new ExpiringGroups<Group>({
|
||||||
max: options.max,
|
max: options.max,
|
||||||
|
maxWeight: this.maxOctets,
|
||||||
now: options.now,
|
now: options.now,
|
||||||
onSweep: () => { this.sweep(); },
|
onSweep: () => { this.sweep(); },
|
||||||
timeout: options.timeout,
|
timeout: options.timeout,
|
||||||
});
|
});
|
||||||
this.log = options.log;
|
this.log = options.log;
|
||||||
this.max = options.max;
|
this.max = options.max;
|
||||||
this.maxOctets = options.maxOctets ?? defaultMaxOctets;
|
|
||||||
this.newId = options.newId ?? uuidv7;
|
this.newId = options.newId ?? uuidv7;
|
||||||
this.onLost = options.onLost;
|
this.onLost = options.onLost;
|
||||||
}
|
}
|
||||||
@@ -163,25 +117,17 @@ export class Reassembler {
|
|||||||
if (!this.placeable(concat, existing)) return { kept: false, refusal: 'unplaceable' };
|
if (!this.placeable(concat, existing)) return { kept: false, refusal: 'unplaceable' };
|
||||||
|
|
||||||
const group = existing ?? this.open(key, concat.total);
|
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));
|
|
||||||
|
|
||||||
group.parts.set(concat.part, segment);
|
group.parts.set(concat.part, detach(pduObj));
|
||||||
group.octets += delta;
|
|
||||||
this.octets += delta;
|
|
||||||
|
|
||||||
if (group.parts.size < group.total) {
|
if (group.parts.size < group.total) {
|
||||||
this.trim(key);
|
|
||||||
|
|
||||||
// Its own arrival overran the octet cap, so the peer keeps it rather than being told we did.
|
// Its own arrival overran the octet cap, so the peer keeps it rather than being told we did.
|
||||||
if (this.groups.get(key) !== group) return { kept: false, refusal: 'full' };
|
if (!this.trim(key, group)) return { kept: false, refusal: 'full' };
|
||||||
|
|
||||||
return { kept: true, smsId: group.smsId };
|
return { kept: true, smsId: group.smsId };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.groups.delete(key);
|
this.groups.delete(key);
|
||||||
this.octets -= group.octets;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kept: true,
|
kept: true,
|
||||||
@@ -194,14 +140,11 @@ export class Reassembler {
|
|||||||
for (const [, group] of this.groups.takeAll()) {
|
for (const [, group] of this.groups.takeAll()) {
|
||||||
this.lost(group, 'linkGone');
|
this.lost(group, 'linkGone');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.octets = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
|
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
|
||||||
sweep(): void {
|
sweep(): void {
|
||||||
for (const [, group] of this.groups.takeExpired()) {
|
for (const [, group] of this.groups.takeExpired()) {
|
||||||
this.octets -= group.octets;
|
|
||||||
this.lost(group, 'expired');
|
this.lost(group, 'expired');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,37 +177,37 @@ export class Reassembler {
|
|||||||
private open(key: string, total: number): Group {
|
private open(key: string, total: number): Group {
|
||||||
if (this.groups.full) this.dropOldest();
|
if (this.groups.full) this.dropOldest();
|
||||||
|
|
||||||
const group: Group = { octets: 0, parts: new Map(), smsId: this.newId(), total };
|
const group: Group = { parts: new Map(), smsId: this.newId(), total };
|
||||||
|
|
||||||
this.groups.set(key, group);
|
this.groups.set(key, group);
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drops the oldest groups until the retained payload is back under the octet cap. */
|
/** Drops the oldest groups until the retained payload is back under the octet cap. False if the current one went. */
|
||||||
private trim(current: string): void {
|
private trim(current: string, group: Group): boolean {
|
||||||
while (this.octets > this.maxOctets) {
|
let octets = 0;
|
||||||
const oldest = this.takeOldest();
|
|
||||||
|
|
||||||
if (!oldest) return;
|
for (const part of group.parts.values()) {
|
||||||
|
octets += retainedOctets(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
let survived = true;
|
||||||
|
|
||||||
|
for (const [key, oldest] of this.groups.weigh(current, octets)) {
|
||||||
|
if (key === current) survived = false;
|
||||||
|
|
||||||
// The refused segment is in the group but stays with the peer, so it is none of the loss.
|
// The refused segment is in the group but stays with the peer, so it is none of the loss.
|
||||||
const answered = oldest[0] === current ? oldest[1].parts.size - 1 : oldest[1].parts.size;
|
const answered = key === current ? oldest.parts.size - 1 : oldest.parts.size;
|
||||||
|
|
||||||
if (answered > 0) this.lost(oldest[1], 'evicted', answered);
|
if (answered > 0) this.lost(oldest, 'evicted', answered);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private takeOldest(): [string, Group] | undefined {
|
return survived;
|
||||||
const oldest = this.groups.takeOldest();
|
|
||||||
|
|
||||||
if (oldest) this.octets -= oldest[1].octets;
|
|
||||||
|
|
||||||
return oldest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private dropOldest(): void {
|
private dropOldest(): void {
|
||||||
const oldest = this.takeOldest();
|
const oldest = this.groups.takeOldest();
|
||||||
|
|
||||||
if (oldest) this.lost(oldest[1], 'evicted');
|
if (oldest) this.lost(oldest[1], 'evicted');
|
||||||
}
|
}
|
||||||
@@ -277,7 +220,7 @@ export class Reassembler {
|
|||||||
...lost,
|
...lost,
|
||||||
max: this.max,
|
max: this.max,
|
||||||
maxOctets: this.maxOctets,
|
maxOctets: this.maxOctets,
|
||||||
octets: this.octets,
|
octets: this.groups.weight,
|
||||||
});
|
});
|
||||||
this.onLost(lost);
|
this.onLost(lost);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { SmsIdNotation } from './sms-id.ts';
|
||||||
import { UnansweredError } from './unanswered-error.ts';
|
import { UnansweredError } from './unanswered-error.ts';
|
||||||
import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.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 { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
|
||||||
import { namedValue } from './error-from.ts';
|
import { namedValue } from './error-from.ts';
|
||||||
import { normaliseSmsId } from './sms-id.ts';
|
import { normaliseSmsId } from './sms-id.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
|
||||||
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
||||||
|
|
||||||
export type SendSmsOptions = {
|
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');
|
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. */
|
/** 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> {
|
function checkOptions(sms: SendSmsInput): Result<CheckedOptions> {
|
||||||
|
const unwritable = checkAddresses(sms);
|
||||||
|
|
||||||
|
if (unwritable) return { err: unwritable };
|
||||||
|
|
||||||
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
const mode = checkMessagingMode(sms.messagingMode, sms.dlr === true);
|
||||||
|
|
||||||
if (mode.err) return { err: mode.err };
|
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 });
|
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
|
// Segments go out together: a receiver that waits for every segment before answering would otherwise deadlock.
|
||||||
// segment before answering — this library's own server does — would otherwise deadlock.
|
|
||||||
const sent = await Promise.all(segments.map(segment => deps.send({
|
const sent = await Promise.all(segments.map(segment => deps.send({
|
||||||
cmdName: 'submit_sm',
|
cmdName: 'submit_sm',
|
||||||
params: submitSmParams(sms, segment, {
|
params: submitSmParams(sms, segment, {
|
||||||
|
|||||||
+24
-26
@@ -1,18 +1,19 @@
|
|||||||
import type { CloseOptions, OnRequest } from './session-options.ts';
|
import type { BindType, 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 { Result, VoidResult } from './result.ts';
|
||||||
import type { Server as NetServer, Socket } from 'node:net';
|
import type { Server as NetServer, Socket } from 'node:net';
|
||||||
import type { Server as TlsServer, TlsOptions } from 'node:tls';
|
import type { Server as TlsServer, TlsOptions } from 'node:tls';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session, bindCommands, defaultSystemId } from './session.ts';
|
import { Session, defaultSystemId } from './session.ts';
|
||||||
import { bindTypeFromCommand, checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
|
import { bindTypeFromCommand, checkSessionOptions } from './session-options.ts';
|
||||||
import { createServer as createNetServer } from 'node:net';
|
import { createServer as createNetServer } from 'node:net';
|
||||||
import { createServer as createTlsServer } from 'node:tls';
|
import { createServer as createTlsServer } from 'node:tls';
|
||||||
import { defaultInterfaceVersion } from './defs/constants.ts';
|
import { defaultInterfaceVersion } from './defs/constants.ts';
|
||||||
import { errorFrom } from './error-from.ts';
|
import { errorFrom } from './error-from.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { paramText } from './defs/types.ts';
|
||||||
import { guardedLog } from './log.ts';
|
import { guardedLog } from './log.ts';
|
||||||
|
import { respNameFor } from './defs/commands.ts';
|
||||||
|
|
||||||
export type AuthenticateResult = { userData?: unknown } | boolean;
|
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. */
|
/** 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;
|
if (!session.acceptsOptionalParams()) return undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -169,24 +170,12 @@ function bindRespTlvs(session: Session, options: ServerOptions): Record<string,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acceptBind(
|
async function onBind(
|
||||||
session: Session,
|
session: Session,
|
||||||
pduObj: PduObject,
|
pduObj: PduObject,
|
||||||
|
bindType: BindType,
|
||||||
options: ServerOptions,
|
options: ServerOptions,
|
||||||
identity: Record<string, string>,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const declared = pduObj.params.interface_version;
|
|
||||||
|
|
||||||
session.boundAs = bindTypeFromCommand(pduObj.cmdName);
|
|
||||||
session.loggedIn = true;
|
|
||||||
session.peerInterfaceVersion = typeof declared === 'number'
|
|
||||||
? declared
|
|
||||||
: undeclaredInterfaceVersion;
|
|
||||||
|
|
||||||
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onBind(session: Session, pduObj: PduObject, options: ServerOptions): Promise<void> {
|
|
||||||
const identity = { system_id: options.systemId ?? defaults.systemId };
|
const identity = { system_id: options.systemId ?? defaults.systemId };
|
||||||
const systemId = paramText(pduObj.params.system_id);
|
const systemId = paramText(pduObj.params.system_id);
|
||||||
|
|
||||||
@@ -197,7 +186,16 @@ async function onBind(session: Session, pduObj: PduObject, options: ServerOption
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await acceptBind(session, pduObj, options, identity);
|
const recorded = session.bound(bindType, pduObj.params.interface_version);
|
||||||
|
|
||||||
|
if (recorded.err) {
|
||||||
|
session.log.info('server - bind refused', { message: recorded.err.message, systemId });
|
||||||
|
await session.sendReturn(pduObj, 'ESME_RBINDFAIL', identity);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
|
||||||
session.log.verbose('server - bound', { systemId });
|
session.log.verbose('server - bound', { systemId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,21 +208,21 @@ async function handleRequest(
|
|||||||
pduObj: PduObject,
|
pduObj: PduObject,
|
||||||
options: ServerOptions,
|
options: ServerOptions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const isBind = bindCommands.includes(pduObj.cmdName);
|
const bindType = bindTypeFromCommand(pduObj.cmdName);
|
||||||
|
|
||||||
if (session.loggedIn) {
|
if (session.boundAs !== undefined) {
|
||||||
if (isBind || !options.onRequest) return false;
|
if (bindType || !options.onRequest) return false;
|
||||||
|
|
||||||
return options.onRequest(session, pduObj);
|
return options.onRequest(session, pduObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBind) {
|
if (bindType) {
|
||||||
await onBind(session, pduObj, options);
|
await onBind(session, pduObj, bindType, options);
|
||||||
|
|
||||||
return true;
|
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 });
|
session.log.debug('server - command before bind', { cmdName: pduObj.cmdName });
|
||||||
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
||||||
|
|||||||
+64
-8
@@ -9,6 +9,7 @@ import type { SmsIdFormat } from './sms-id.ts';
|
|||||||
import type { Sms } from './sms.ts';
|
import type { Sms } from './sms.ts';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
import { backoffDefaults } from './reconnect-loop.ts';
|
import { backoffDefaults } from './reconnect-loop.ts';
|
||||||
|
import { defaultMaxOctets } from './reassembly.ts';
|
||||||
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
||||||
import { namedValue } from './error-from.ts';
|
import { namedValue } from './error-from.ts';
|
||||||
|
|
||||||
@@ -120,6 +121,31 @@ export const defaultSystemId = '';
|
|||||||
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
|
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
|
||||||
export const undeclaredInterfaceVersion = 0x00;
|
export const undeclaredInterfaceVersion = 0x00;
|
||||||
|
|
||||||
|
export type SessionBind = { as: BindType; peerVersion: number };
|
||||||
|
|
||||||
|
function quoted(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? JSON.stringify(value) : namedValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBindType(value: unknown): value is BindType {
|
||||||
|
return typeof value === 'string' && bindTypeFromCommand(`bind_${value}`) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bind as `Session.bound()` records it: undefined declares no version, which is pre-3.4. */
|
||||||
|
export function checkedBind(bindType: unknown, declaredVersion: unknown): Result<{ bind: SessionBind }> {
|
||||||
|
if (!isBindType(bindType)) {
|
||||||
|
return { err: new Error(`bindType must be receiver, transceiver or transmitter, the bind command's name without "bind_", got ${quoted(bindType)}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declaredVersion === undefined) return { bind: { as: bindType, peerVersion: undeclaredInterfaceVersion } };
|
||||||
|
|
||||||
|
if (typeof declaredVersion !== 'number' || !Number.isInteger(declaredVersion) || declaredVersion < 0 || declaredVersion > 0xFF) {
|
||||||
|
return { err: new Error(`declaredVersion must be an integer 0-255, the interface_version param or the sc_interface_version TLV's tagValue, or undefined where the peer declared none, got ${quoted(declaredVersion)}`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bind: { as: bindType, peerVersion: declaredVersion } };
|
||||||
|
}
|
||||||
|
|
||||||
export const defaults = {
|
export const defaults = {
|
||||||
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
|
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
|
||||||
dlrMergeTimeout: 86_400_000,
|
dlrMergeTimeout: 86_400_000,
|
||||||
@@ -127,6 +153,7 @@ export const defaults = {
|
|||||||
heldMessageTimeout: 300_000,
|
heldMessageTimeout: 300_000,
|
||||||
maxDlrMerges: 1000,
|
maxDlrMerges: 1000,
|
||||||
maxHeldMessages: 1000,
|
maxHeldMessages: 1000,
|
||||||
|
maxHeldOctets: 64 * 1024 * 1024,
|
||||||
maxOutstanding: 10,
|
maxOutstanding: 10,
|
||||||
maxReassembly: 1000,
|
maxReassembly: 1000,
|
||||||
reassemblyTimeout: 300_000,
|
reassemblyTimeout: 300_000,
|
||||||
@@ -144,14 +171,11 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
|
|||||||
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
|
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
|
||||||
}
|
}
|
||||||
|
|
||||||
const checked = checkLimits([
|
const connect = checkConnectTimeout(options.connectTimeout);
|
||||||
['idleTimeout', options.idleTimeout ?? 0, 0],
|
|
||||||
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
|
if (connect.err) return connect;
|
||||||
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
|
|
||||||
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
|
const checked = checkLimits(limitsOf(options));
|
||||||
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
|
|
||||||
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (checked.err) return checked;
|
if (checked.err) return checked;
|
||||||
|
|
||||||
@@ -160,6 +184,36 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
|
|||||||
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat);
|
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function limitsOf(options: CheckableOptions): [string, number, number][] {
|
||||||
|
return [
|
||||||
|
['idleTimeout', options.idleTimeout ?? 0, 0],
|
||||||
|
['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 = quoted(connectTimeout);
|
||||||
|
|
||||||
|
if (typeof connectTimeout !== 'number' || !Number.isInteger(connectTimeout) || connectTimeout < 1) {
|
||||||
|
return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; false waits the OS out instead`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectTimeout > maxTimerDelay) {
|
||||||
|
return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} ms or less (about 24 days), got ${got}; false waits the OS out instead`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
function checkLimits(limits: [string, number, number][]): VoidResult {
|
function checkLimits(limits: [string, number, number][]): VoidResult {
|
||||||
for (const [name, value, min] of limits) {
|
for (const [name, value, min] of limits) {
|
||||||
if (!Number.isInteger(value) || value < min) {
|
if (!Number.isInteger(value) || value < min) {
|
||||||
@@ -238,9 +292,11 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
|
|||||||
|
|
||||||
/** What the checker reads, as it arrives: a caller without types can put anything in it. */
|
/** What the checker reads, as it arrives: a caller without types can put anything in it. */
|
||||||
export type CheckableOptions = {
|
export type CheckableOptions = {
|
||||||
|
connectTimeout?: unknown;
|
||||||
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
|
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
|
||||||
fromStart?: unknown;
|
fromStart?: unknown;
|
||||||
idleTimeout?: number | undefined;
|
idleTimeout?: number | undefined;
|
||||||
|
maxOctets?: number | undefined;
|
||||||
maxOutstanding?: number | undefined;
|
maxOutstanding?: number | undefined;
|
||||||
maxReassembly?: number | undefined;
|
maxReassembly?: number | undefined;
|
||||||
reassemblyTimeout?: number | undefined;
|
reassemblyTimeout?: number | undefined;
|
||||||
|
|||||||
+70
-36
@@ -1,9 +1,10 @@
|
|||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { ErrorName } from './defs/errors.ts';
|
||||||
|
import type { IncomingDeps } from './incoming-requests.ts';
|
||||||
import type { MessageDlr } from './dlr-merger.ts';
|
import type { MessageDlr } from './dlr-merger.ts';
|
||||||
import type { ParamValue } from './defs/types.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 { PduRefusedError } from './pdu-refusal.ts';
|
||||||
import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
|
import type { BindType, CloseOptions, LinkEnd, OnRequest, ReconnectOptions, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
@@ -18,11 +19,12 @@ import { ReconnectLoop } from './reconnect-loop.ts';
|
|||||||
import { leftOf } from './idle-waiters.ts';
|
import { leftOf } from './idle-waiters.ts';
|
||||||
import { errorFrom } from './error-from.ts';
|
import { errorFrom } from './error-from.ts';
|
||||||
import { optionalParamsMinVersion } from './defs/constants.ts';
|
import { optionalParamsMinVersion } from './defs/constants.ts';
|
||||||
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts';
|
import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts';
|
||||||
import { isResp, objToPdu, pduReturn } from './pdu.ts';
|
import { isResp, objToPdu, pduReturn } from './pdu.ts';
|
||||||
import { refusalAnswer } from './pdu-refusal.ts';
|
import { refusalAnswer } from './pdu-refusal.ts';
|
||||||
import { guardedLog } from './log.ts';
|
import { guardedLog } from './log.ts';
|
||||||
import { submitSms, unsent } from './send-sms.ts';
|
import { submitSms, unsent } from './send-sms.ts';
|
||||||
|
import { createSms } from './sms.ts';
|
||||||
import { ConcatReference } from './udh.ts';
|
import { ConcatReference } from './udh.ts';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -52,15 +54,12 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
|
|
||||||
readonly log: SmppLog;
|
readonly log: SmppLog;
|
||||||
|
|
||||||
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
|
|
||||||
boundAs: BindType | undefined = undefined;
|
|
||||||
/** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */
|
/** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */
|
||||||
linkEnd: LinkEnd = 'esme';
|
linkEnd: LinkEnd = 'esme';
|
||||||
loggedIn = false;
|
|
||||||
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
|
|
||||||
peerInterfaceVersion: number | undefined = undefined;
|
|
||||||
userData: unknown = undefined;
|
userData: unknown = undefined;
|
||||||
|
|
||||||
|
private bind: SessionBind | undefined = undefined;
|
||||||
|
|
||||||
private readonly concatReference = new ConcatReference();
|
private readonly concatReference = new ConcatReference();
|
||||||
private readonly dlrMerger: DlrMerger;
|
private readonly dlrMerger: DlrMerger;
|
||||||
private readonly incoming: IncomingRequests;
|
private readonly incoming: IncomingRequests;
|
||||||
@@ -70,8 +69,8 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
private readonly timers: LinkTimers;
|
private readonly timers: LinkTimers;
|
||||||
private readonly transport: PduTransport;
|
private readonly transport: PduTransport;
|
||||||
|
|
||||||
private closed = false;
|
/** `ended` is final: end() stops the reconnect loop before any attach() can run. */
|
||||||
private ended = false;
|
private lifecycle: 'attached' | 'ended' | 'torn-down' = 'attached';
|
||||||
|
|
||||||
/** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
|
/** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
|
||||||
override emit<K extends keyof SessionEvents>(
|
override emit<K extends keyof SessionEvents>(
|
||||||
@@ -118,14 +117,12 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
timeout: defaults.dlrMergeTimeout,
|
timeout: defaults.dlrMergeTimeout,
|
||||||
});
|
});
|
||||||
this.incoming = new IncomingRequests({
|
this.incoming = new IncomingRequests({
|
||||||
|
deps: this.incomingDeps(options.onRequest),
|
||||||
dlrMerger: this.dlrMerger,
|
dlrMerger: this.dlrMerger,
|
||||||
log: this.log,
|
log: this.log,
|
||||||
maxOctets: options.maxOctets,
|
maxOctets: options.maxOctets,
|
||||||
maxReassembly: options.maxReassembly,
|
maxReassembly: options.maxReassembly,
|
||||||
onRequest: options.onRequest,
|
|
||||||
reassemblyTimeout: options.reassemblyTimeout,
|
reassemblyTimeout: options.reassemblyTimeout,
|
||||||
sendPastDrain: input => this.outgoing.pastDrain(input, {}),
|
|
||||||
session: this,
|
|
||||||
smsIdFormat: options.smsIdFormat,
|
smsIdFormat: options.smsIdFormat,
|
||||||
systemId: options.systemId,
|
systemId: options.systemId,
|
||||||
});
|
});
|
||||||
@@ -154,6 +151,25 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
return this.transport.sock;
|
return this.transport.sock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
|
||||||
|
get boundAs(): BindType | undefined {
|
||||||
|
return this.bind?.as;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
|
||||||
|
get peerInterfaceVersion(): number | undefined {
|
||||||
|
return this.bind?.peerVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a bind this link accepted or had accepted, until the next one. */
|
||||||
|
bound(bindType: string, declaredVersion: unknown): VoidResult {
|
||||||
|
const checked = checkedBind(bindType, declaredVersion);
|
||||||
|
|
||||||
|
if (!checked.err) this.bind = checked.bind;
|
||||||
|
|
||||||
|
return checked.err ? { err: checked.err } : {};
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */
|
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */
|
||||||
bindAllows(cmdName: string): boolean {
|
bindAllows(cmdName: string): boolean {
|
||||||
return bindCarries(this.boundAs, cmdName, this.linkEnd);
|
return bindCarries(this.boundAs, cmdName, this.linkEnd);
|
||||||
@@ -161,8 +177,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
|
|
||||||
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
|
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
|
||||||
acceptsOptionalParams(): boolean {
|
acceptsOptionalParams(): boolean {
|
||||||
return this.peerInterfaceVersion === undefined
|
return this.peerInterfaceVersion === undefined || this.peerInterfaceVersion >= optionalParamsMinVersion;
|
||||||
|| this.peerInterfaceVersion >= optionalParamsMinVersion;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a request and resolves with the peer's response. */
|
/** Sends a request and resolves with the peer's response. */
|
||||||
@@ -171,22 +186,20 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Answers a request the peer sent us. Responses are never waited on. */
|
/** Answers a request the peer sent us. Responses are never waited on. */
|
||||||
async sendReturn(
|
sendReturn(
|
||||||
pdu: PduObject,
|
pdu: PduObject,
|
||||||
status: ErrorName = 'ESME_ROK',
|
status: ErrorName = 'ESME_ROK',
|
||||||
params: Record<string, ParamValue> = {},
|
params: Record<string, ParamValue> = {},
|
||||||
tlvs?: Record<string, TlvInput>,
|
tlvs?: TlvInputs,
|
||||||
): Promise<VoidResult> {
|
): Promise<VoidResult> {
|
||||||
return Promise.resolve(
|
return Promise.resolve(this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr));
|
||||||
this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
|
private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
|
||||||
const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
|
const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
|
||||||
|
|
||||||
// A peer that unbinds and drops the link takes our response with it; that is not a failure.
|
// A peer that unbinds and drops the link takes our response with it; that is not a failure.
|
||||||
if (sent.err && !this.closed) {
|
if (sent.err && this.lifecycle === 'attached') {
|
||||||
this.log.warn('session - could not answer a request', {
|
this.log.warn('session - could not answer a request', {
|
||||||
cmdName,
|
cmdName,
|
||||||
message: sent.err.message,
|
message: sent.err.message,
|
||||||
@@ -221,12 +234,11 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
*/
|
*/
|
||||||
async unbind(): Promise<VoidResult> {
|
async unbind(): Promise<VoidResult> {
|
||||||
const drained = await this.drain(undefined);
|
const drained = await this.drain(undefined);
|
||||||
const wasOpen = !this.closed;
|
const wasOpen = this.lifecycle === 'attached';
|
||||||
// now(), not send(): a drain refuses a send, and the unbind goes out either way.
|
|
||||||
const sent = wasOpen
|
const sent = wasOpen
|
||||||
? await this.outgoing.now({ cmdName: 'unbind' })
|
? await this.outgoing.requestPastDrainGateAndWindow({ cmdName: 'unbind' })
|
||||||
: { err: new Error('Session is closed') };
|
: { err: new Error('Session is closed') };
|
||||||
const closedOnUnbind = wasOpen && this.closed;
|
const closedOnUnbind = wasOpen && this.lifecycle !== 'attached';
|
||||||
|
|
||||||
this.end();
|
this.end();
|
||||||
|
|
||||||
@@ -245,6 +257,27 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
return drained;
|
return drained;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private incomingDeps(onRequest: OnRequest | undefined): IncomingDeps {
|
||||||
|
return {
|
||||||
|
acceptsOptionalParams: () => this.acceptsOptionalParams(),
|
||||||
|
answer: (pduObj, status, params) => this.sendReturn(pduObj, status, params),
|
||||||
|
bindAllows: cmdName => this.bindAllows(cmdName),
|
||||||
|
boundAs: () => this.boundAs,
|
||||||
|
createSms: (input, handlers) => createSms({ ...input, session: this }, handlers),
|
||||||
|
linkEnd: () => this.linkEnd,
|
||||||
|
offerSms: sms => this.emit('sms', sms),
|
||||||
|
onRequest: onRequest && (pduObj => onRequest(this, pduObj)),
|
||||||
|
// A peer that has said it is finished will not answer what we still have outstanding.
|
||||||
|
peerUnbound: () => this.close({ signal: AbortSignal.abort() }),
|
||||||
|
reportDlr: (dlr, pduObj) => { this.emit('dlr', dlr, pduObj); },
|
||||||
|
reportError: err => { this.emit('sessionError', err); },
|
||||||
|
reportMessageDlr: merged => { this.emit('messageDlr', merged); },
|
||||||
|
send: input => this.send(input),
|
||||||
|
sendPastDrain: input => this.outgoing.requestPastDrain(input, {}),
|
||||||
|
smsListeners: () => this.listenerCount('sms'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private transportFor(sock: Socket): PduTransport {
|
private transportFor(sock: Socket): PduTransport {
|
||||||
return new PduTransport({
|
return new PduTransport({
|
||||||
log: this.log,
|
log: this.log,
|
||||||
@@ -304,7 +337,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
|
|
||||||
private attach(sock: Socket): void {
|
private attach(sock: Socket): void {
|
||||||
this.transport.attach(sock);
|
this.transport.attach(sock);
|
||||||
this.closed = false;
|
this.lifecycle = 'attached';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stops new sends and waits out the messages we hold and the requests already issued. */
|
/** Stops new sends and waits out the messages we hold and the requests already issued. */
|
||||||
@@ -312,7 +345,8 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
this.reconnectLoop?.stop();
|
this.reconnectLoop?.stop();
|
||||||
this.outgoing.stopAccepting();
|
this.outgoing.stopAccepting();
|
||||||
|
|
||||||
if (this.outgoing.linkDown()) return {};
|
// No link, so nothing is on the wire to wait out.
|
||||||
|
if (!this.outgoing.canCarry()) return {};
|
||||||
|
|
||||||
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
|
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
|
||||||
const deadline = timeout > 0 ? Date.now() + timeout : 0;
|
const deadline = timeout > 0 ? Date.now() + timeout : 0;
|
||||||
@@ -320,8 +354,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
const messages = await this.incoming.drain(this.answering(timeout), signal);
|
const messages = await this.incoming.drain(this.answering(timeout), signal);
|
||||||
const requests = await this.outgoing.drain(leftOf(deadline), signal);
|
const requests = await this.outgoing.drain(leftOf(deadline), signal);
|
||||||
|
|
||||||
// The window empties on a teardown too, which settles everything the link was carrying.
|
if (this.outgoing.droppedWhileDraining()) {
|
||||||
if (this.outgoing.linkDown()) {
|
|
||||||
return { err: new Error('The session closed before the drain finished') };
|
return { err: new Error('The session closed before the drain finished') };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,17 +383,17 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitClose(): void {
|
private emitClose(): void {
|
||||||
if (this.ended) return;
|
if (this.lifecycle === 'ended') return;
|
||||||
|
|
||||||
this.ended = true;
|
this.lifecycle = 'ended';
|
||||||
this.outgoing.linkLost(false);
|
this.outgoing.linkLost(false);
|
||||||
this.emit('close');
|
this.emit('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
private teardown(): void {
|
private teardown(): void {
|
||||||
if (this.closed) return;
|
if (this.lifecycle !== 'attached') return;
|
||||||
|
|
||||||
this.closed = true;
|
this.lifecycle = 'torn-down';
|
||||||
|
|
||||||
// Read once: clear() reports lost segments, and a listener could stop the loop between reads.
|
// Read once: clear() reports lost segments, and a listener could stop the loop between reads.
|
||||||
const retrying = this.retrying();
|
const retrying = this.retrying();
|
||||||
@@ -374,6 +407,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
else this.emitClose();
|
else this.emitClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copied into the gate at teardown, so stopping the loop anywhere but drain() and end() has to shut the gate too.
|
||||||
private retrying(): boolean {
|
private retrying(): boolean {
|
||||||
return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped();
|
return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped();
|
||||||
}
|
}
|
||||||
@@ -423,15 +457,15 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resetTimers(): void {
|
private resetTimers(): void {
|
||||||
if (this.closed) return;
|
if (this.lifecycle !== 'attached') return;
|
||||||
|
|
||||||
this.timers.reset();
|
this.timers.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onClose(): void {
|
private onClose(): void {
|
||||||
if (this.reconnectLoop && !this.reconnectLoop.isStopped()) {
|
if (this.retrying()) {
|
||||||
this.teardown();
|
this.teardown();
|
||||||
this.reconnectLoop.schedule();
|
this.reconnectLoop?.schedule();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-9
@@ -1,6 +1,7 @@
|
|||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { ErrorName } from './defs/errors.ts';
|
||||||
import type { MessageState } from './defs/constants.ts';
|
import type { MessageState } from './defs/constants.ts';
|
||||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
import type { ParamValue } from './defs/types.ts';
|
||||||
|
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
import type { Session } from './session.ts';
|
import type { Session } from './session.ts';
|
||||||
import { UnansweredError } from './unanswered-error.ts';
|
import { UnansweredError } from './unanswered-error.ts';
|
||||||
@@ -68,6 +69,9 @@ export type SmsInput = {
|
|||||||
|
|
||||||
/** What the session's incoming side gives a message so it can be answered and accounted for. */
|
/** What the session's incoming side gives a message so it can be answered and accounted for. */
|
||||||
export type SmsHandlers = {
|
export type SmsHandlers = {
|
||||||
|
acceptsOptionalParams: () => boolean;
|
||||||
|
answer: (pduObj: PduObject, status: ErrorName, params: Record<string, ParamValue>) => Promise<VoidResult>;
|
||||||
|
bindAllows: (cmdName: string) => boolean;
|
||||||
lostLink: () => boolean;
|
lostLink: () => boolean;
|
||||||
onAnswered: () => void;
|
onAnswered: () => void;
|
||||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||||
@@ -89,7 +93,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
|||||||
from: input.from,
|
from: input.from,
|
||||||
message: input.message,
|
message: input.message,
|
||||||
pduObjs: input.pduObjs,
|
pduObjs: input.pduObjs,
|
||||||
sendDlr: status => sendDlr(sms, handlers.send, status),
|
sendDlr: status => sendDlr(sms, handlers, status),
|
||||||
sendResp: options => (input.answeredAs === undefined
|
sendResp: options => (input.answeredAs === undefined
|
||||||
? sendResp(sms, answered, options ?? {}, handlers)
|
? sendResp(sms, answered, options ?? {}, handlers)
|
||||||
: answeredOnArrival(options ?? {}, handlers)),
|
: answeredOnArrival(options ?? {}, handlers)),
|
||||||
@@ -130,7 +134,7 @@ async function sendResp(
|
|||||||
sms: Sms,
|
sms: Sms,
|
||||||
answered: { smsId: string },
|
answered: { smsId: string },
|
||||||
options: SendRespOptions,
|
options: SendRespOptions,
|
||||||
handlers: Pick<SmsHandlers, 'lostLink' | 'onAnswered'>,
|
handlers: Pick<SmsHandlers, 'answer' | 'lostLink' | 'onAnswered'>,
|
||||||
): Promise<VoidResult> {
|
): Promise<VoidResult> {
|
||||||
const total = sms.pduObjs.length;
|
const total = sms.pduObjs.length;
|
||||||
|
|
||||||
@@ -149,7 +153,7 @@ async function sendResp(
|
|||||||
return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') };
|
return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') };
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(sms.pduObjs.map((pduObj, index) => sms.session.sendReturn(
|
const results = await Promise.all(sms.pduObjs.map((pduObj, index) => handlers.answer(
|
||||||
pduObj,
|
pduObj,
|
||||||
options.status ?? 'ESME_ROK',
|
options.status ?? 'ESME_ROK',
|
||||||
respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)),
|
respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)),
|
||||||
@@ -179,7 +183,7 @@ function receiptText(sms: Sms, smsId: string, status: MessageState): string {
|
|||||||
].join(' ');
|
].join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function receiptTlvs(smsId: string, status: MessageState): Record<string, TlvInput> {
|
function receiptTlvs(smsId: string, status: MessageState): TlvInputs {
|
||||||
return {
|
return {
|
||||||
message_state: { tagValue: consts.MESSAGE_STATE[status] },
|
message_state: { tagValue: consts.MESSAGE_STATE[status] },
|
||||||
receipted_message_id: { tagValue: smsId },
|
receipted_message_id: { tagValue: smsId },
|
||||||
@@ -210,10 +214,10 @@ function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult {
|
|||||||
|
|
||||||
async function sendDlr(
|
async function sendDlr(
|
||||||
sms: Sms,
|
sms: Sms,
|
||||||
send: SmsHandlers['send'],
|
handlers: Pick<SmsHandlers, 'acceptsOptionalParams' | 'bindAllows' | 'send'>,
|
||||||
status: MessageState = 'DELIVERED',
|
status: MessageState = 'DELIVERED',
|
||||||
): Promise<SendDlrResult> {
|
): Promise<SendDlrResult> {
|
||||||
if (!sms.session.bindAllows('deliver_sm')) {
|
if (!handlers.bindAllows('deliver_sm')) {
|
||||||
return {
|
return {
|
||||||
err: new Error('A transmitter-bound session does not carry deliver_sm'),
|
err: new Error('A transmitter-bound session does not carry deliver_sm'),
|
||||||
pduObjs: [],
|
pduObjs: [],
|
||||||
@@ -226,7 +230,7 @@ async function sendDlr(
|
|||||||
const sent = await Promise.all(sms.pduObjs.map((_segment, index) => {
|
const sent = await Promise.all(sms.pduObjs.map((_segment, index) => {
|
||||||
const smsId = segmentId(sms.smsId, index, total);
|
const smsId = segmentId(sms.smsId, index, total);
|
||||||
|
|
||||||
return send({
|
return handlers.send({
|
||||||
cmdName: 'deliver_sm',
|
cmdName: 'deliver_sm',
|
||||||
params: {
|
params: {
|
||||||
destination_addr: sms.from,
|
destination_addr: sms.from,
|
||||||
@@ -236,7 +240,7 @@ async function sendDlr(
|
|||||||
short_message: receiptText(sms, smsId, status),
|
short_message: receiptText(sms, smsId, status),
|
||||||
source_addr: sms.to,
|
source_addr: sms.to,
|
||||||
},
|
},
|
||||||
...(sms.session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}),
|
...(handlers.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}),
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
return collectReceipt(sent);
|
return collectReceipt(sent);
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import { consts } from '../src/defs/constants.ts';
|
|||||||
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
|
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
|
||||||
import { encodeMessage } from '../src/message.ts';
|
import { encodeMessage } from '../src/message.ts';
|
||||||
import { objToPdu, pduToObj } from '../src/pdu.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';
|
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(
|
function deliverSm(
|
||||||
message: Buffer | string,
|
message: Buffer | string,
|
||||||
tlvs?: Record<string, TlvInput>,
|
tlvs?: TlvInputs,
|
||||||
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||||
dataCoding = 0,
|
dataCoding = 0,
|
||||||
): PduObject {
|
): PduObject {
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ describe('our encoder against the reference parser', () => {
|
|||||||
},
|
},
|
||||||
seqNr: 77,
|
seqNr: 77,
|
||||||
tlvs: {
|
tlvs: {
|
||||||
message_state: { tagId: 0x0427, tagValue: 2 },
|
message_state: { tagValue: 2 },
|
||||||
receipted_message_id: { tagId: 0x001E, tagValue: 'abc123' },
|
receipted_message_id: { tagValue: 'abc123' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test, { describe } from 'node:test';
|
import test, { describe } from 'node:test';
|
||||||
import type { Dlr, Receipt } from '../src/dlr.ts';
|
import type { Dlr, Receipt } from '../src/dlr.ts';
|
||||||
import type { MessageDlr } from '../src/session.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 { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
import { consts } from '../src/defs/constants.ts';
|
import { consts } from '../src/defs/constants.ts';
|
||||||
import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts';
|
import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts';
|
||||||
@@ -16,7 +16,7 @@ import { objToPdu, pduToObj } from '../src/pdu.ts';
|
|||||||
|
|
||||||
function deliverSm(
|
function deliverSm(
|
||||||
body: string,
|
body: string,
|
||||||
tlvs?: Record<string, TlvInput>,
|
tlvs?: TlvInputs,
|
||||||
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
esmClass: number = consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||||
): PduObject {
|
): PduObject {
|
||||||
const { buffer } = objToPdu({
|
const { buffer } = objToPdu({
|
||||||
@@ -53,7 +53,7 @@ type ReceiptFixture = {
|
|||||||
name: string;
|
name: string;
|
||||||
receipt: Receipt;
|
receipt: Receipt;
|
||||||
source: string;
|
source: string;
|
||||||
tlvs?: Record<string, TlvInput>;
|
tlvs?: TlvInputs;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fixtures: readonly ReceiptFixture[] = [
|
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 { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts';
|
||||||
import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
|
import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
|
||||||
import { paramText } from '../src/defs/types.ts';
|
import { paramText } from '../src/defs/types.ts';
|
||||||
|
import { isTlvName, tlvsById } from '../src/defs/tlvs.ts';
|
||||||
|
|
||||||
function encode(...args: Parameters<typeof objToPdu>): Buffer {
|
function encode(...args: Parameters<typeof objToPdu>): Buffer {
|
||||||
const { buffer, err } = objToPdu(...args);
|
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: 0x80000001 })).seqNr, 0x80000001);
|
||||||
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF);
|
assert.equal(decode(encode({ cmdName: 'enquire_link', seqNr: 0xFFFFFFFF })).seqNr, 0xFFFFFFFF);
|
||||||
assert.ok(objToPdu({ cmdName: 'submit_sm', seqNr: 0x100000000 }).err instanceof Error);
|
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.params.short_message, 'hej 一');
|
||||||
assert.equal(pduObj.tlvs.message_state?.tagValue, 2);
|
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', () => {
|
describe('encoding submit_sm', () => {
|
||||||
@@ -342,7 +378,7 @@ describe('TLVs', () => {
|
|||||||
},
|
},
|
||||||
seqNr: 393,
|
seqNr: 393,
|
||||||
tlvs: {
|
tlvs: {
|
||||||
5142: { tagId: 5142, tagValue: Buffer.from('blajfoo', 'ascii') },
|
5142: { tagValue: Buffer.from('blajfoo', 'ascii') },
|
||||||
receipted_message_id: { tagValue: '293f293' },
|
receipted_message_id: { tagValue: '293f293' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -356,7 +392,7 @@ describe('TLVs', () => {
|
|||||||
assert.deepEqual(unknown.tagValue, Buffer.from('blajfoo', 'ascii'));
|
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 payload = Buffer.from('deadbeef00ff', 'hex');
|
||||||
const params = {
|
const params = {
|
||||||
destination_addr: '46709771337',
|
destination_addr: '46709771337',
|
||||||
@@ -364,16 +400,21 @@ describe('TLVs', () => {
|
|||||||
short_message: 'binary payload follows',
|
short_message: 'binary payload follows',
|
||||||
source_addr: '46701113311',
|
source_addr: '46701113311',
|
||||||
};
|
};
|
||||||
const parsed = decode(encode({
|
const pdu = encode({
|
||||||
cmdName: 'deliver_sm',
|
cmdName: 'deliver_sm',
|
||||||
params,
|
params,
|
||||||
seqNr: 7,
|
seqNr: 7,
|
||||||
tlvs: { message_payload: { tagValue: payload } },
|
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.ok(carried);
|
||||||
assert.deepEqual(carried.tagValue, payload);
|
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({
|
const rebuilt = decode(encode({
|
||||||
cmdName: 'deliver_sm',
|
cmdName: 'deliver_sm',
|
||||||
@@ -397,25 +438,27 @@ describe('TLVs', () => {
|
|||||||
assert.deepEqual(pduObj.tlvs.source_port, { tagId: 0x020A, tagName: 'source_port', tagValue: 1234 });
|
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', () => {
|
test('refuses a TLV keyed any way but by its name, or by its decimal id where the table names none', () => {
|
||||||
const { buffer, err } = objToPdu({
|
const params = { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' };
|
||||||
cmdName: 'deliver_sm',
|
const refusals = [
|
||||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
// @ts-expect-error nils is no tag name
|
||||||
tlvs: { nils: { tagValue: 'blajfoo' } },
|
{ 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);
|
for (const { built: { buffer, err }, reason } of refusals) {
|
||||||
assert.ok(err instanceof Error);
|
assert.equal(buffer, undefined);
|
||||||
});
|
assert.ok(err instanceof Error);
|
||||||
|
assert.match(err.message, reason);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('refuses a TLV too long for the two octet length field', () => {
|
test('refuses a TLV too long for the two octet length field', () => {
|
||||||
@@ -428,6 +471,118 @@ describe('TLVs', () => {
|
|||||||
assert.ok(err instanceof Error);
|
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', () => {
|
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 receipt = 'id:450 sub:001 dlvrd:1 submit date:1504031342 done date:1504031342 stat:DELIVRD err:0 text:xxx';
|
||||||
const pduObj = decode(encode({
|
const pduObj = decode(encode({
|
||||||
@@ -440,8 +595,8 @@ describe('TLVs', () => {
|
|||||||
},
|
},
|
||||||
seqNr: 323,
|
seqNr: 323,
|
||||||
tlvs: {
|
tlvs: {
|
||||||
message_state: { tagId: 1063, tagValue: 2 },
|
message_state: { tagValue: 2 },
|
||||||
receipted_message_id: { tagId: 30, tagValue: 450 },
|
receipted_message_id: { tagValue: 450 },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+378
-57
@@ -4,6 +4,7 @@ import test, { describe } from 'node:test';
|
|||||||
import type { Collected, LostGroup } from '../src/reassembly.ts';
|
import type { Collected, LostGroup } from '../src/reassembly.ts';
|
||||||
import type { Dlr } from '../src/dlr.ts';
|
import type { Dlr } from '../src/dlr.ts';
|
||||||
import type { ErrorName } from '../src/defs/errors.ts';
|
import type { ErrorName } from '../src/defs/errors.ts';
|
||||||
|
import type { IncomingDeps } from '../src/incoming-requests.ts';
|
||||||
import type { MessageState } from '../src/defs/constants.ts';
|
import type { MessageState } from '../src/defs/constants.ts';
|
||||||
import type { MessageDlr } from '../src/session.ts';
|
import type { MessageDlr } from '../src/session.ts';
|
||||||
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
|
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
|
||||||
@@ -24,7 +25,7 @@ import { Session } from '../src/session.ts';
|
|||||||
import { DlrMerger } from '../src/dlr-merger.ts';
|
import { DlrMerger } from '../src/dlr-merger.ts';
|
||||||
import { PduRefusedError } from '../src/pdu-refusal.ts';
|
import { PduRefusedError } from '../src/pdu-refusal.ts';
|
||||||
import { objToPdu } from '../src/pdu.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 { client } from '../src/client.ts';
|
||||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||||
import { concatOf } from '../src/concat.ts';
|
import { concatOf } from '../src/concat.ts';
|
||||||
@@ -84,6 +85,41 @@ function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
|
|||||||
return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
|
return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A client still retrying holds a socket and a timer nothing else releases. */
|
||||||
|
function abortAfter(
|
||||||
|
t: TestContext,
|
||||||
|
controller: AbortController,
|
||||||
|
connecting: ReturnType<typeof client>,
|
||||||
|
): void {
|
||||||
|
t.after(async () => {
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const { session } = await connecting;
|
||||||
|
|
||||||
|
await session?.close({ signal: AbortSignal.abort() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubPort(session: Session, port: Partial<IncomingDeps> = {}): IncomingDeps {
|
||||||
|
return {
|
||||||
|
acceptsOptionalParams: () => true,
|
||||||
|
answer: (pduObj, status, params) => session.sendReturn(pduObj, status, params),
|
||||||
|
bindAllows: () => true,
|
||||||
|
boundAs: () => 'transceiver',
|
||||||
|
createSms: (input, handlers) => createSms({ ...input, session }, handlers),
|
||||||
|
linkEnd: () => 'smsc',
|
||||||
|
offerSms: sms => session.emit('sms', sms),
|
||||||
|
peerUnbound: () => Promise.resolve({}),
|
||||||
|
reportDlr: () => undefined,
|
||||||
|
reportError: () => undefined,
|
||||||
|
reportMessageDlr: () => undefined,
|
||||||
|
send: () => Promise.resolve({ err: new Error('never sent') }),
|
||||||
|
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
|
||||||
|
smsListeners: () => session.listenerCount('sms'),
|
||||||
|
...port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
|
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
|
||||||
return {
|
return {
|
||||||
cmdId: 0x00000004,
|
cmdId: 0x00000004,
|
||||||
@@ -592,6 +628,10 @@ describe('reconnect', () => {
|
|||||||
|
|
||||||
const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
|
const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
|
||||||
const halfPdu = once<true>(resolve => { session.on('data', () => { resolve(true); }); });
|
const halfPdu = once<true>(resolve => { session.on('data', () => { resolve(true); }); });
|
||||||
|
const boundBefore = [session.boundAs, session.peerInterfaceVersion];
|
||||||
|
const whileDown = once<unknown[]>(resolve => {
|
||||||
|
session.on('disconnected', () => { resolve([session.boundAs, session.peerInterfaceVersion]); });
|
||||||
|
});
|
||||||
|
|
||||||
// A PDU header promising 32 octets and sending 8: the next link must not continue it.
|
// A PDU header promising 32 octets and sending 8: the next link must not continue it.
|
||||||
peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4]));
|
peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4]));
|
||||||
@@ -604,7 +644,9 @@ describe('reconnect', () => {
|
|||||||
|
|
||||||
await reconnected;
|
await reconnected;
|
||||||
|
|
||||||
assert.ok(session.loggedIn);
|
// The loop rebinds as before, so the gap keeps answering bindAllows() for the bind to come.
|
||||||
|
assert.deepEqual(await whileDown, boundBefore);
|
||||||
|
assert.equal(session.boundAs, 'transceiver');
|
||||||
|
|
||||||
// The session object survives the drop, so listeners stay attached and it is usable again.
|
// The session object survives the drop, so listeners stay attached and it is usable again.
|
||||||
const sent = await session.sendSms({
|
const sent = await session.sendSms({
|
||||||
@@ -714,14 +756,11 @@ describe('reconnect', () => {
|
|||||||
const session = new Session({ sock: new net.Socket() });
|
const session = new Session({ sock: new net.Socket() });
|
||||||
|
|
||||||
closeAfter(t, session);
|
closeAfter(t, session);
|
||||||
session.boundAs = 'transceiver';
|
|
||||||
|
|
||||||
const incoming = new IncomingRequests({
|
const incoming = new IncomingRequests({
|
||||||
|
deps: stubPort(session, { onRequest: async () => { await delay(10); return false; } }),
|
||||||
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
||||||
log: silentLog,
|
log: silentLog,
|
||||||
onRequest: async () => { await delay(10); return false; },
|
|
||||||
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
|
|
||||||
session,
|
|
||||||
});
|
});
|
||||||
let messages = 0;
|
let messages = 0;
|
||||||
|
|
||||||
@@ -798,21 +837,6 @@ describe('reconnect from the first bind', () => {
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A client still retrying holds a socket and a timer nothing else releases. */
|
|
||||||
function abortAfter(
|
|
||||||
t: TestContext,
|
|
||||||
controller: AbortController,
|
|
||||||
connecting: ReturnType<typeof client>,
|
|
||||||
): void {
|
|
||||||
t.after(async () => {
|
|
||||||
controller.abort();
|
|
||||||
|
|
||||||
const { session } = await connecting;
|
|
||||||
|
|
||||||
await session?.close({ signal: AbortSignal.abort() });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('gives up on the first attempt where reconnect alone is asked for', async () => {
|
test('gives up on the first attempt where reconnect alone is asked for', async () => {
|
||||||
const port = await closedPort();
|
const port = await closedPort();
|
||||||
const spy = logSpy();
|
const spy = logSpy();
|
||||||
@@ -849,7 +873,7 @@ describe('reconnect from the first bind', () => {
|
|||||||
|
|
||||||
assert.equal(err, undefined);
|
assert.equal(err, undefined);
|
||||||
assert.ok(session);
|
assert.ok(session);
|
||||||
assert.equal(session.loggedIn, true);
|
assert.equal(session.boundAs, 'transceiver');
|
||||||
assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts');
|
assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts');
|
||||||
assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay');
|
assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -1018,6 +1042,121 @@ describe('reconnect from the first bind', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('connectTimeout', () => {
|
||||||
|
/** Accepts and then says nothing, so a TLS handshake started on it never completes. */
|
||||||
|
async function stalledListener(t: TestContext): Promise<{ accepted: net.Socket[]; port: number }> {
|
||||||
|
const accepted: net.Socket[] = [];
|
||||||
|
const listener = net.createServer(sock => {
|
||||||
|
accepted.push(sock);
|
||||||
|
sock.resume();
|
||||||
|
});
|
||||||
|
|
||||||
|
closeListenerAfter(t, listener, accepted);
|
||||||
|
await new Promise<void>(resolve => { listener.listen(0, '127.0.0.1', resolve); });
|
||||||
|
|
||||||
|
const address = listener.address();
|
||||||
|
|
||||||
|
return { accepted, port: typeof address === 'object' && address !== null ? address.port : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('gives up on a connect the peer never completes', async t => {
|
||||||
|
const { port } = await stalledListener(t);
|
||||||
|
const settled = await within(2000, client({
|
||||||
|
connectTimeout: 150,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
reconnect: false,
|
||||||
|
tls: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.ok(settled, 'a handshake nothing answers is what the OS wait would swallow for minutes');
|
||||||
|
assert.ok(settled.err instanceof Error);
|
||||||
|
assert.match(
|
||||||
|
settled.err.message,
|
||||||
|
new RegExp(`Timed out completing the TLS handshake with 127\\.0\\.0\\.1:${String(port)} after 150 ms; raise connectTimeout`),
|
||||||
|
'a firewall and a peer that accepts then stalls need different answers, and whoever reads this has never heard of the option',
|
||||||
|
);
|
||||||
|
assert.equal(settled.session, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Waits the default out for real: node:test mock timers land in Node 20.4, and the floor is 18.
|
||||||
|
test('bounds a connect nobody asked to bound, at the default ten seconds', async t => {
|
||||||
|
const { port } = await stalledListener(t);
|
||||||
|
const connecting = client({ host: '127.0.0.1', port, reconnect: false, tls: true });
|
||||||
|
const settled = await within(13_000, connecting);
|
||||||
|
|
||||||
|
assert.ok(settled, 'no bound was armed, so nothing ever settled this connect');
|
||||||
|
assert.ok(settled.err instanceof Error);
|
||||||
|
assert.match(settled.err.message, /after 10000 ms/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retries a connect it timed out on, like any other failed attempt', async t => {
|
||||||
|
const { accepted, port } = await stalledListener(t);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const connecting = client({
|
||||||
|
connectTimeout: 60,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
|
||||||
|
signal: controller.signal,
|
||||||
|
tls: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
abortAfter(t, controller, connecting);
|
||||||
|
await delay(400);
|
||||||
|
|
||||||
|
assert.ok(accepted.length >= 3, `the loop retried what timed out, got ${String(accepted.length)} attempts`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disarms on the connect that completed, rather than on the socket that follows it', async t => {
|
||||||
|
const smpp = await startServer(t);
|
||||||
|
const { session } = await connect(t, smpp, { connectTimeout: 200 });
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
await delay(300);
|
||||||
|
|
||||||
|
const probe = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(probe.err, undefined);
|
||||||
|
assert.equal(session.sock.destroyed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a connect timeout that would turn itself off', async () => {
|
||||||
|
assert.match(
|
||||||
|
checkSessionOptions({ connectTimeout: 0 }).err?.message ?? '',
|
||||||
|
/false waits the OS out/,
|
||||||
|
'off is spelled false, so 0 may not stand in for it',
|
||||||
|
);
|
||||||
|
assert.equal(checkSessionOptions({ connectTimeout: false }).err, undefined);
|
||||||
|
assert.match(checkSessionOptions({ connectTimeout: -1 }).err?.message ?? '', /connectTimeout/);
|
||||||
|
assert.match(checkSessionOptions({ connectTimeout: 1.5 }).err?.message ?? '', /whole number/);
|
||||||
|
assert.match(
|
||||||
|
checkSessionOptions({ connectTimeout: '5000' }).err?.message ?? '',
|
||||||
|
/got "5000"/,
|
||||||
|
'an env var read without Number() is the commonest untyped value, and it is a correct number',
|
||||||
|
);
|
||||||
|
assert.match(checkSessionOptions({ connectTimeout: true }).err?.message ?? '', /got true/);
|
||||||
|
assert.match(
|
||||||
|
checkSessionOptions({ connectTimeout: 2_147_483_648 }).err?.message ?? '',
|
||||||
|
/2147483647 ms or less \(about 24 days\), got 2147483648; false waits the OS out instead/,
|
||||||
|
'a delay Node cannot hold in 32 bits fires after 1 ms, the inverse of what it asked for',
|
||||||
|
);
|
||||||
|
assert.equal(checkSessionOptions({ connectTimeout: 2_147_483_647 }).err, undefined);
|
||||||
|
assert.equal(checkSessionOptions({ connectTimeout: 1000 }).err, undefined);
|
||||||
|
|
||||||
|
const refused = await client({ connectTimeout: 0, port: 1 });
|
||||||
|
|
||||||
|
assert.ok(refused.err instanceof Error);
|
||||||
|
assert.match(refused.err.message, /false waits/, 'the socket may not be opened before the option is refused');
|
||||||
|
assert.equal(refused.session, undefined);
|
||||||
|
|
||||||
|
const off = await client({ connectTimeout: false, port: 1 });
|
||||||
|
|
||||||
|
assert.ok(off.err instanceof Error);
|
||||||
|
assert.match(off.err.message, /ECONNREFUSED/, 'false opts out of the bound without breaking the connect');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sends across a reconnect', () => {
|
describe('sends across a reconnect', () => {
|
||||||
/** Answers every message after the first, which is left to hold the send window open. */
|
/** Answers every message after the first, which is left to hold the send window open. */
|
||||||
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
|
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
|
||||||
@@ -1315,6 +1454,18 @@ describe('LinkGate', () => {
|
|||||||
|
|
||||||
assert.match(held.err?.message ?? '', /Aborted while waiting for a link/);
|
assert.match(held.err?.message ?? '', /Aborted while waiting for a link/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('awaits the next link only while shut with one on its way', () => {
|
||||||
|
const gate = new LinkGate({ log: silentLog, timeout: 100 });
|
||||||
|
|
||||||
|
assert.equal(gate.awaitsNextLink(), false, 'up');
|
||||||
|
gate.shut(true);
|
||||||
|
assert.equal(gate.awaitsNextLink(), true, 'shut, returning');
|
||||||
|
gate.open();
|
||||||
|
assert.equal(gate.awaitsNextLink(), false, 'reopened');
|
||||||
|
gate.shut(false);
|
||||||
|
assert.equal(gate.awaitsNextLink(), false, 'shut for good');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SendWindow', () => {
|
describe('SendWindow', () => {
|
||||||
@@ -1367,34 +1518,134 @@ describe('held message bounds', () => {
|
|||||||
return [submitPdu(seqNr)];
|
return [submitPdu(seqNr)];
|
||||||
}
|
}
|
||||||
|
|
||||||
test('drops the message held longest rather than holding every one', () => {
|
test('is full at its count, and a re-used sequence number replaces rather than adding', () => {
|
||||||
const held = new HeldMessages({ log: silentLog, max: 2, timeout: 10_000 });
|
const held = new HeldMessages({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 });
|
||||||
const oldest = message(1);
|
const first = held.hold(message(1), 1);
|
||||||
|
|
||||||
held.hold(oldest);
|
held.hold(message(2), 1);
|
||||||
held.hold(message(2));
|
held.hold(message(2), 1);
|
||||||
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.size, 2);
|
||||||
assert.equal(held.has(oldest), false);
|
assert.equal(held.octetsHeld, 2 * 1026, 'the replaced message leaves its octets with it');
|
||||||
|
assert.equal(held.full(), true);
|
||||||
|
assert.equal(first.isHeld(), true);
|
||||||
|
|
||||||
held.clear();
|
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 = held.hold(message(1), 1);
|
||||||
|
|
||||||
|
assert.equal(held.full(), false);
|
||||||
|
held.hold(message(2), 1);
|
||||||
|
assert.equal(held.full(), true);
|
||||||
|
|
||||||
|
answered.release();
|
||||||
|
assert.equal(held.full(), false, 'after a release');
|
||||||
|
held.hold(message(3), 1);
|
||||||
|
|
||||||
|
now = 20_000;
|
||||||
|
held.sweep();
|
||||||
|
now = 0;
|
||||||
|
assert.equal(held.full(), false, 'after a sweep');
|
||||||
|
held.hold(message(4), 1);
|
||||||
|
held.hold(message(5), 1);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const incoming = new IncomingRequests({
|
||||||
|
deps: stubPort(session),
|
||||||
|
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
||||||
|
log: { ...silentLog, warn: message => { warnings.push(message); } },
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
|
||||||
|
const incoming = new IncomingRequests({
|
||||||
|
deps: stubPort(session),
|
||||||
|
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
||||||
|
log: silentLog,
|
||||||
|
});
|
||||||
|
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', () => {
|
test('gives up on a message the application never answers', () => {
|
||||||
let now = 0;
|
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));
|
held.hold(message(1), 1);
|
||||||
now = 61;
|
now = 61;
|
||||||
|
|
||||||
// The next message sweeps the one that expired, so only the new one is still waited for.
|
// The next message sweeps the one that expired, so only the new one is still waited for.
|
||||||
held.hold(message(2));
|
held.hold(message(2), 1);
|
||||||
|
|
||||||
assert.equal(held.size, 1);
|
assert.equal(held.size, 1);
|
||||||
|
|
||||||
@@ -1404,9 +1655,9 @@ describe('held message bounds', () => {
|
|||||||
// Without this the drain sits out its whole budget before returning what a sweep already settled.
|
// 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 () => {
|
test('wakes a waiting drain when the last message expires', async () => {
|
||||||
let now = 0;
|
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));
|
held.hold(message(1), 1);
|
||||||
|
|
||||||
const waiting = held.idle(1000, undefined);
|
const waiting = held.idle(1000, undefined);
|
||||||
|
|
||||||
@@ -1420,11 +1671,9 @@ describe('held message bounds', () => {
|
|||||||
describe('sendResp()', () => {
|
describe('sendResp()', () => {
|
||||||
// A response the wire never carried leaves the peer owed one, so nothing may count it answered.
|
// A response the wire never carried leaves the peer owed one, so nothing may count it answered.
|
||||||
test('does not count a response that never reached the wire as an answer', async t => {
|
test('does not count a response that never reached the wire as an answer', async t => {
|
||||||
const sock = new net.Socket();
|
const session = new Session({ sock: new net.Socket() });
|
||||||
const session = new Session({ sock });
|
|
||||||
|
|
||||||
closeAfter(t, session);
|
closeAfter(t, session);
|
||||||
sock.destroy();
|
|
||||||
|
|
||||||
let answered = 0;
|
let answered = 0;
|
||||||
const sms = createSms({
|
const sms = createSms({
|
||||||
@@ -1434,6 +1683,9 @@ describe('sendResp()', () => {
|
|||||||
session,
|
session,
|
||||||
to: '46709771337',
|
to: '46709771337',
|
||||||
}, {
|
}, {
|
||||||
|
acceptsOptionalParams: () => true,
|
||||||
|
answer: () => Promise.resolve({ err: new Error('Socket is closed') }),
|
||||||
|
bindAllows: () => true,
|
||||||
lostLink: () => false,
|
lostLink: () => false,
|
||||||
onAnswered: () => { answered++; },
|
onAnswered: () => { answered++; },
|
||||||
send: () => Promise.resolve({ err: new Error('never sent') }),
|
send: () => Promise.resolve({ err: new Error('never sent') }),
|
||||||
@@ -1459,6 +1711,9 @@ describe('sendDlr()', () => {
|
|||||||
session,
|
session,
|
||||||
to: '46709771337',
|
to: '46709771337',
|
||||||
}, {
|
}, {
|
||||||
|
acceptsOptionalParams: () => true,
|
||||||
|
answer: () => Promise.resolve({}),
|
||||||
|
bindAllows: () => true,
|
||||||
lostLink: () => false,
|
lostLink: () => false,
|
||||||
onAnswered: () => undefined,
|
onAnswered: () => undefined,
|
||||||
send: () => {
|
send: () => {
|
||||||
@@ -1588,7 +1843,7 @@ describe('where a segment says it is concatenated', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('reads no concatenation where a sar_* TLV is missing', () => {
|
test('reads no concatenation where a sar_* TLV is missing', () => {
|
||||||
const lone = {
|
const lone: PduObject = {
|
||||||
...sarSegment(5, 1, 2),
|
...sarSegment(5, 1, 2),
|
||||||
tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } },
|
tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } },
|
||||||
};
|
};
|
||||||
@@ -1690,7 +1945,7 @@ describe('reassembly bounds', () => {
|
|||||||
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
|
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', () => {
|
test('counts a body carried in message_payload against the octet cap', () => {
|
||||||
function collectPayload(maxOctets: number): Collected {
|
function collectPayload(maxOctets: number): Collected {
|
||||||
const reassembler = new Reassembler({
|
const reassembler = new Reassembler({
|
||||||
@@ -1705,8 +1960,45 @@ describe('reassembly bounds', () => {
|
|||||||
return collectPdu(reassembler, payloadSegment(9, 1, 2));
|
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(1330).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
|
||||||
assert.equal(collectPayload(40).kept, true);
|
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.
|
// The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one.
|
||||||
@@ -1715,8 +2007,8 @@ describe('reassembly bounds', () => {
|
|||||||
const reassembler = new Reassembler({
|
const reassembler = new Reassembler({
|
||||||
log: silentLog,
|
log: silentLog,
|
||||||
max: 10,
|
max: 10,
|
||||||
// One segment is 36 octets, so the second overruns a group already holding the first.
|
// One segment is 1036 octets, so the second overruns a group already holding the first.
|
||||||
maxOctets: 50,
|
maxOctets: 1050,
|
||||||
now: () => 0,
|
now: () => 0,
|
||||||
onLost: one => { lost.push(one); },
|
onLost: one => { lost.push(one); },
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
@@ -1780,9 +2072,9 @@ describe('reassembly bounds', () => {
|
|||||||
assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would');
|
assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would');
|
||||||
counted.clear();
|
counted.clear();
|
||||||
|
|
||||||
// A sar_* segment is the two 11-octet addresses plus an 8-octet body, with no UDH to carry.
|
// A sar_* segment is the two 11-octet addresses, an 8-octet body, its own object and three TLVs'.
|
||||||
assert.equal(collectSar(capped(20), 3, 1, 2).kept, false);
|
assert.equal(collectSar(capped(1920), 3, 1, 2).kept, false);
|
||||||
assert.equal(collectSar(capped(30), 3, 1, 2).kept, true);
|
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.
|
// Nothing else says a message the peer has already been answered for was thrown away.
|
||||||
@@ -1898,8 +2190,8 @@ describe('reassembly bounds', () => {
|
|||||||
const reassembler = new Reassembler({
|
const reassembler = new Reassembler({
|
||||||
log: silentLog,
|
log: silentLog,
|
||||||
max: 10,
|
max: 10,
|
||||||
// One segment is 36 octets: 14 of short_message plus the two 11-octet addresses.
|
// One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object.
|
||||||
maxOctets: 80,
|
maxOctets: 2100,
|
||||||
now: () => 0,
|
now: () => 0,
|
||||||
onLost: () => undefined,
|
onLost: () => undefined,
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
@@ -1918,6 +2210,8 @@ describe('reassembly bounds', () => {
|
|||||||
|
|
||||||
assert.ok(reopened.kept);
|
assert.ok(reopened.kept);
|
||||||
assert.equal(reopened.whole, undefined);
|
assert.equal(reopened.whole, undefined);
|
||||||
|
assert.ok(collect(reassembler, 3, 1, 2).kept);
|
||||||
|
assert.equal(reassembler.size, 2, 'a segment sent again replaces its octets rather than adding them');
|
||||||
|
|
||||||
reassembler.clear();
|
reassembler.clear();
|
||||||
});
|
});
|
||||||
@@ -1982,16 +2276,15 @@ describe('reassembly bounds', () => {
|
|||||||
// Jasmin dispatches one request per connector at a time: holding a group unanswered until it was
|
// 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).
|
// whole deadlocked every multi-segment message against it (interop-tests/findings/03-jasmin.md).
|
||||||
describe('the status a refused segment is answered with', () => {
|
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', () => {
|
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('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
|
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
|
||||||
assert.equal(refusedSegmentStatus('deliver_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.
|
// 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('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
|
||||||
assert.equal(refusedSegmentStatus('deliver_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.
|
// Which command that is, for the one that travels both ways, is what the end it arrived at says.
|
||||||
@@ -2001,7 +2294,7 @@ describe('the status a refused segment is answered with', () => {
|
|||||||
assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm');
|
assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm');
|
||||||
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
|
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
|
||||||
assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
|
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');
|
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2814,6 +3107,34 @@ describe('graceful shutdown', () => {
|
|||||||
|
|
||||||
assert.deepEqual(reported, []);
|
assert.deepEqual(reported, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('answers a peer\'s unbind before asking the session to end', async t => {
|
||||||
|
const session = new Session({ sock: new net.Socket() });
|
||||||
|
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
const incoming = new IncomingRequests({
|
||||||
|
deps: stubPort(session, {
|
||||||
|
answer: pduObj => {
|
||||||
|
calls.push(pduObj.cmdName);
|
||||||
|
|
||||||
|
return Promise.resolve({});
|
||||||
|
},
|
||||||
|
peerUnbound: () => {
|
||||||
|
calls.push('peerUnbound');
|
||||||
|
|
||||||
|
return Promise.resolve({});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
||||||
|
log: silentLog,
|
||||||
|
});
|
||||||
|
|
||||||
|
await incoming.handle({ ...submitPdu(1), cmdId: 0x00000006, cmdName: 'unbind', params: {} });
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['unbind', 'peerUnbound']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('message id notation', () => {
|
describe('message id notation', () => {
|
||||||
|
|||||||
+86
-5
@@ -12,6 +12,7 @@ import { DlrMerger } from '../src/dlr-merger.ts';
|
|||||||
import { PduFramer } from '../src/pdu-framer.ts';
|
import { PduFramer } from '../src/pdu-framer.ts';
|
||||||
import { ReconnectLoop } from '../src/reconnect-loop.ts';
|
import { ReconnectLoop } from '../src/reconnect-loop.ts';
|
||||||
import { Session, bindCommands } from '../src/session.ts';
|
import { Session, bindCommands } from '../src/session.ts';
|
||||||
|
import { checkSessionOptions } from '../src/session-options.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client.ts';
|
||||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||||
import { consts } from '../src/defs/constants.ts';
|
import { consts } from '../src/defs/constants.ts';
|
||||||
@@ -228,7 +229,7 @@ describe('bind', () => {
|
|||||||
|
|
||||||
assert.equal(err, undefined);
|
assert.equal(err, undefined);
|
||||||
assert.ok(session);
|
assert.ok(session);
|
||||||
assert.ok(session.loggedIn);
|
assert.equal(session.boundAs, 'transceiver');
|
||||||
|
|
||||||
assert.deepEqual(await session.unbind(), {});
|
assert.deepEqual(await session.unbind(), {});
|
||||||
});
|
});
|
||||||
@@ -316,6 +317,25 @@ describe('bind', () => {
|
|||||||
assert.equal(await responded, '00000010800000150000000400000001');
|
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 => {
|
test('answers the enquire_link a bound peer sends', async t => {
|
||||||
const smpp = await startServer(t);
|
const smpp = await startServer(t);
|
||||||
const peer = rawPeer(t, smpp.port);
|
const peer = rawPeer(t, smpp.port);
|
||||||
@@ -428,6 +448,35 @@ describe('bind', () => {
|
|||||||
assert.ok(session.acceptsOptionalParams());
|
assert.ok(session.acceptsOptionalParams());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('records a hand-wired bind through bound(), and nothing else writes it', t => {
|
||||||
|
const session = new Session({ sock: new net.Socket() });
|
||||||
|
|
||||||
|
closeAfter(t, session);
|
||||||
|
assert.equal(session.boundAs, undefined);
|
||||||
|
assert.equal(session.peerInterfaceVersion, undefined);
|
||||||
|
|
||||||
|
assert.deepEqual(session.bound('receiver', 0x34), {});
|
||||||
|
assert.equal(session.boundAs, 'receiver');
|
||||||
|
assert.equal(session.peerInterfaceVersion, 0x34);
|
||||||
|
assert.equal(session.bindAllows('submit_sm'), false);
|
||||||
|
|
||||||
|
assert.deepEqual(session.bound('transmitter', undefined), {});
|
||||||
|
assert.equal(session.peerInterfaceVersion, 0x00, 'no declared version is pre-3.4');
|
||||||
|
|
||||||
|
// The likeliest mistakes: the TLV object for its value, a version past int8, the command's name.
|
||||||
|
assert.match(session.bound('receiver', { tagValue: 0x34 }).err?.message ?? '', /tagValue, or undefined .* got object$/);
|
||||||
|
assert.ok(session.bound('receiver', 0x100).err);
|
||||||
|
assert.ok(session.bound('receiver', 3.4).err);
|
||||||
|
assert.match(session.bound('bind_receiver', 0x34).err?.message ?? '', /without "bind_", got "bind_receiver"$/);
|
||||||
|
|
||||||
|
assert.equal(session.boundAs, 'transmitter', 'a refused bind leaves the recorded one alone');
|
||||||
|
assert.equal(session.peerInterfaceVersion, 0x00);
|
||||||
|
assert.equal(Reflect.set(session, 'boundAs', 'transceiver'), false);
|
||||||
|
assert.equal(Reflect.set(session, 'peerInterfaceVersion', 0x50), false);
|
||||||
|
assert.equal(session.boundAs, 'transmitter');
|
||||||
|
assert.equal('loggedIn' in session, false);
|
||||||
|
});
|
||||||
|
|
||||||
// The spec: an absent sc_interface_version means the SMSC supports no optional parameters.
|
// The spec: an absent sc_interface_version means the SMSC supports no optional parameters.
|
||||||
test('takes an SMSC that declares no version as older than 3.4', async t => {
|
test('takes an SMSC that declares no version as older than 3.4', async t => {
|
||||||
const peer = await smscPeer(t);
|
const peer = await smscPeer(t);
|
||||||
@@ -890,8 +939,8 @@ describe('receiving', () => {
|
|||||||
|
|
||||||
// The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
|
// 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 => {
|
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.
|
// 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: 30 });
|
const smpp = await startServer(t, { maxOctets: 1330 });
|
||||||
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
||||||
|
|
||||||
assert.ok(session);
|
assert.ok(session);
|
||||||
@@ -912,7 +961,7 @@ describe('receiving', () => {
|
|||||||
|
|
||||||
assert.ok(refused.pduObj);
|
assert.ok(refused.pduObj);
|
||||||
assert.equal(refused.pduObj.cmdName, 'data_sm_resp');
|
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 => {
|
test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
|
||||||
@@ -1493,7 +1542,6 @@ describe('robustness', () => {
|
|||||||
// 0.4.0 registered a listener per sequence number and waited forever, leaking one per call.
|
// 0.4.0 registered a listener per sequence number and waited forever, leaking one per call.
|
||||||
test('gives up on a peer that never answers', async t => {
|
test('gives up on a peer that never answers', async t => {
|
||||||
const accepted: net.Socket[] = [];
|
const accepted: net.Socket[] = [];
|
||||||
// resume() so the socket drains; an unread socket never notices the peer hanging up.
|
|
||||||
const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); });
|
const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); });
|
||||||
|
|
||||||
await new Promise<void>(resolve => silent.listen(0, resolve));
|
await new Promise<void>(resolve => silent.listen(0, resolve));
|
||||||
@@ -1508,6 +1556,30 @@ describe('robustness', () => {
|
|||||||
assert.ok(Date.now() - started < 5000, 'should have given up quickly');
|
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 () => {
|
test('stops a connection attempt on an aborted signal', async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -2646,6 +2718,15 @@ describe('option validation', () => {
|
|||||||
assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/);
|
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 () => {
|
test('returns an error rather than rejecting on an impossible port', async () => {
|
||||||
const listening = await server({ port: 70_000 });
|
const listening = await server({ port: 70_000 });
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -135,7 +135,7 @@ describe('tls', () => {
|
|||||||
|
|
||||||
assert.equal(err, undefined);
|
assert.equal(err, undefined);
|
||||||
assert.ok(session);
|
assert.ok(session);
|
||||||
assert.ok(session.loggedIn);
|
assert.equal(session.boundAs, 'transceiver');
|
||||||
closeAfter(t, session);
|
closeAfter(t, session);
|
||||||
|
|
||||||
const sock = session.sock;
|
const sock = session.sock;
|
||||||
|
|||||||
+114
-2
@@ -1,8 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test, { describe } from 'node:test';
|
import test, { describe } from 'node:test';
|
||||||
import type { DestAddress, UnsuccessSme } from '../src/defs/types.ts';
|
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 { tlvs } from '../src/defs/tlvs.ts';
|
||||||
import { types } from '../src/defs/types.ts';
|
|
||||||
|
|
||||||
describe('integers', () => {
|
describe('integers', () => {
|
||||||
test('int8 reads, sizes and writes one octet', () => {
|
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.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.int16.write(1.5, Buffer.alloc(2), 0).err instanceof Error);
|
||||||
assert.ok(types.int8.write('nope', Buffer.alloc(1), 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);
|
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)', () => {
|
describe('cstring (C-Octet String)', () => {
|
||||||
@@ -91,13 +117,39 @@ describe('cstring (C-Octet String)', () => {
|
|||||||
assert.deepEqual(target, encoded);
|
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);
|
const target = Buffer.alloc(4);
|
||||||
|
|
||||||
types.cstring.write(123, target, 0);
|
types.cstring.write(123, target, 0);
|
||||||
|
|
||||||
assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00]));
|
assert.deepEqual(target, Buffer.from([0x31, 0x32, 0x33, 0x00]));
|
||||||
assert.deepEqual(types.cstring.size(123), { size: 4 });
|
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', () => {
|
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', () => {
|
describe('buffer', () => {
|
||||||
const expected = Buffer.from('abcd1234');
|
const expected = Buffer.from('abcd1234');
|
||||||
|
|
||||||
@@ -170,6 +249,20 @@ describe('buffer', () => {
|
|||||||
|
|
||||||
assert.deepEqual(target, expected);
|
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', () => {
|
describe('dest_address_array', () => {
|
||||||
@@ -198,6 +291,8 @@ describe('dest_address_array', () => {
|
|||||||
|
|
||||||
test('sizes every dest_address structure', () => {
|
test('sizes every dest_address structure', () => {
|
||||||
assert.deepEqual(types.dest_address_array.size(expected), { size: 13 });
|
assert.deepEqual(types.dest_address_array.size(expected), { size: 13 });
|
||||||
|
assert.ok(types.dest_address_array.size([{ dl_name: 'a\u0000b' }]).err instanceof Error);
|
||||||
|
assert.ok(types.dest_address_array.size([{ dest_addr_npi: 0, dest_addr_ton: 0, destination_addr: '一' }]).err instanceof Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('writes every dest_address structure', () => {
|
test('writes every dest_address structure', () => {
|
||||||
@@ -206,6 +301,11 @@ describe('dest_address_array', () => {
|
|||||||
types.dest_address_array.write(expected, target, 0);
|
types.dest_address_array.write(expected, target, 0);
|
||||||
|
|
||||||
assert.deepEqual(target, encoded);
|
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', () => {
|
test('refuses a field value the wire cannot hold instead of throwing', () => {
|
||||||
@@ -237,6 +337,12 @@ describe('unsuccess_sme_array', () => {
|
|||||||
|
|
||||||
test('sizes every unsuccess_sme structure', () => {
|
test('sizes every unsuccess_sme structure', () => {
|
||||||
assert.deepEqual(types.unsuccess_sme_array.size(expected), { size: 21 });
|
assert.deepEqual(types.unsuccess_sme_array.size(expected), { size: 21 });
|
||||||
|
|
||||||
|
const unwritable: UnsuccessSme[] = [
|
||||||
|
{ dest_addr_npi: 0, dest_addr_ton: 0, destination_addr: 'a\u0000b', error_status_code: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.ok(types.unsuccess_sme_array.size(unwritable).err instanceof Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('writes every unsuccess_sme structure', () => {
|
test('writes every unsuccess_sme structure', () => {
|
||||||
@@ -245,6 +351,12 @@ describe('unsuccess_sme_array', () => {
|
|||||||
types.unsuccess_sme_array.write(expected, target, 0);
|
types.unsuccess_sme_array.write(expected, target, 0);
|
||||||
|
|
||||||
assert.deepEqual(target, encoded);
|
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', () => {
|
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/);
|
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({
|
const built = objToPdu({
|
||||||
cmdName: 'data_sm',
|
cmdName: 'data_sm',
|
||||||
params: { data_coding: 0x03, destination_addr: to, source_addr: from },
|
params: { data_coding: 0x08, destination_addr: to, source_addr: from },
|
||||||
tlvs: { body: { tagId: 0x0424, tagValue: 'あいう' } },
|
tlvs: { 1060: { tagValue: 'あいう' }, message_payload: { tagValue: 'あいう' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.ok(built.err instanceof Error);
|
assert.ok(built.err instanceof Error);
|
||||||
assert.equal(built.buffer, undefined);
|
assert.equal(built.buffer, undefined);
|
||||||
assert.match(built.err.message, /"body"/);
|
assert.match(built.err.message, /"1060"/);
|
||||||
assert.match(built.err.message, /LATIN1/);
|
assert.match(built.err.message, /message_payload/);
|
||||||
assert.match(built.err.message, /U\+3042/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('leaves data_coding to short_message wherever it carries octets, as messageOctets() reads it', () => {
|
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);
|
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', () => {
|
test('leaves the alphabet to the body TLV wherever short_message carries no octets', () => {
|
||||||
for (const short of [undefined, '', Buffer.alloc(0)]) {
|
for (const short of [undefined, '', Buffer.alloc(0)]) {
|
||||||
const built = objToPdu({
|
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', () => {
|
describe('a time no peer can read', () => {
|
||||||
const invalid = new Date('nope');
|
const invalid = new Date('nope');
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# todo.md
|
# todo.md
|
||||||
|
|
||||||
Remaining work for `@larvit/smpp`. Read [AGENTS.md](AGENTS.md) first — the goals and hard rules
|
Remaining work for `@larvit/smpp`. Read [README.md](README.md)'s goals and [AGENTS.md](AGENTS.md)'s
|
||||||
there constrain every item below.
|
hard rules first — they constrain every item below.
|
||||||
|
|
||||||
This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not
|
This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not
|
||||||
govern it, and nothing here is a source anything else may cite.
|
govern it, and nothing here is a source anything else may cite.
|
||||||
@@ -9,7 +9,9 @@ govern it, and nothing here is a source anything else may cite.
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
|
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18
|
||||||
to 26. What is left is release work and a few things worth adding before or after 0.5.0.
|
to 26, and 0.5.0 is on npm. 0.6.0 is next, and it is a quality cut rather than a feature one — the
|
||||||
|
comprehension gate and the defects under [0.6.0](#060) come first, then the gaps a comparison with
|
||||||
|
other SMPP libraries found.
|
||||||
|
|
||||||
## The agreed API
|
## The agreed API
|
||||||
|
|
||||||
@@ -73,79 +75,92 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
|
|||||||
|
|
||||||
## Move the repository to Gitea
|
## Move the repository to Gitea
|
||||||
|
|
||||||
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` becomes a push mirror
|
`gitea.larvit.se/larvit/smpp-js` is the repository. `github.com/larvit/smpp-js` mirrors it and is the
|
||||||
of it and the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
|
place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.
|
||||||
|
|
||||||
- [x] `larvit/smpp-js` holds `main`, from `typescript`, and `v0.4.0`, from `master`. `rewrite-base`
|
- [x] **Move `main` and `v0.4.0` to `larvit/smpp-js`.** It holds `main`, from `typescript`, and
|
||||||
and the `renovate/*` branches stayed behind.
|
`v0.4.0`, from `master`. `rewrite-base` and the `renovate/*` branches stayed behind.
|
||||||
- [x] Fast-forward is the only merge style. `main` takes no pushes, requires `Test / lint
|
- [x] **Protect `main` with fast-forward merges and required checks.** Fast-forward is the only
|
||||||
(pull_request)` and `Test / test (*) (pull_request)`, blocks an outdated branch, and gives
|
merge style. `main` takes no pushes, requires `Test / lint (pull_request)` and `Test / test
|
||||||
admins no override. Every other branch takes force pushes.
|
(*) (pull_request)`, blocks an outdated branch, and gives admins no override. Every other
|
||||||
- [x] The workflows are in `.gitea/workflows/`. Tests run on pull requests only, the event the gate
|
branch takes force pushes.
|
||||||
reads; Renovate runs as a scheduled workflow, as on adf-codec.
|
- [x] **Run the workflows from `.gitea/workflows/`.** Tests run on pull requests only, the event the
|
||||||
- [x] The release publishes without provenance, which npm generates only on GitHub Actions and
|
gate reads; Renovate runs as a scheduled workflow, as on adf-codec.
|
||||||
GitLab CI/CD.
|
- [x] **Publish the release without provenance.** npm generates it only on GitHub Actions and GitLab
|
||||||
- [x] `package.json` names Gitea, and the GitHub mirror's issues as `bugs`. The README links
|
CI/CD.
|
||||||
|
- [x] **Name Gitea in `package.json`, and the GitHub mirror's issues as `bugs`.** The README links
|
||||||
absolutely: npmjs.com resolves a relative link against itself when the `repository` is not on
|
absolutely: npmjs.com resolves a relative link against itself when the `repository` is not on
|
||||||
GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no
|
GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no
|
||||||
workflow runs on `main`.
|
workflow runs on `main`.
|
||||||
- `RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as
|
- **Add a github.com token for Renovate only if its lookups hit the rate limit.**
|
||||||
|
`RENOVATE_GITHUB_TOKEN` exists nowhere, so Renovate queries github.com unauthenticated, as
|
||||||
adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and
|
adf-codec's nightly run already does without a warning. `RENOVATE_TOKEN` is the Gitea token and
|
||||||
cannot stand in for it. Add a github.com token only if lookups hit the rate limit.
|
cannot stand in for it.
|
||||||
|
|
||||||
## Before publishing 0.5.0
|
## Before publishing 0.5.0
|
||||||
|
|
||||||
- [x] 0.5.0 rather than 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
|
- [x] **Publish the rewrite as 0.5.0, not 1.0.0, while usage is this low.** Maintainer's call,
|
||||||
- [x] `NPM_TOKEN`, which `.gitea/workflows/release.yaml` needs, is a Gitea organization secret.
|
2026-09-14.
|
||||||
- [ ] Tag `v0.5.0` on Gitea to publish. The first publish creates `@larvit/smpp` on npm, provided the
|
- [x] **Hold `NPM_TOKEN` as a Gitea organization secret.** `.gitea/workflows/release.yaml` needs it.
|
||||||
token can publish under `@larvit`.
|
- [x] **Tag `v0.5.0` on Gitea to publish.** The first publish creates `@larvit/smpp` on npm,
|
||||||
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
|
provided the token can publish under `@larvit`.
|
||||||
something CI should do.
|
- [x] **Deprecate `larvitsmpp` on npm, pointing at `@larvit/smpp`.** `npm deprecate larvitsmpp` is
|
||||||
|
the maintainer's call to run; not something CI should do.
|
||||||
|
|
||||||
## Retire the GitHub repository
|
## Retire the GitHub repository
|
||||||
|
|
||||||
Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order:
|
Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order:
|
||||||
the push mirror replaces GitHub's branches with Gitea's, which closes every pull request based on
|
deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub
|
||||||
`master` without a reply, and GitHub refuses to delete the default branch it syncs over.
|
refuses to delete its default branch.
|
||||||
|
|
||||||
- [ ] Close the backlog below.
|
- [x] **Close the backlog below.**
|
||||||
- [ ] Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.
|
- [x] **Close [#71](https://github.com/larvit/larvitsmpp/pull/71), pointing at Gitea.**
|
||||||
- [ ] Rename `larvit/larvitsmpp` to `larvit/smpp-js`. GitHub redirects the old URLs, and the `bugs`
|
- [x] **Rename `larvit/larvitsmpp` to `larvit/smpp-js`.** GitHub redirects the old URLs, and the
|
||||||
URL in `package.json` resolves from then on.
|
`bugs` URL in `package.json` resolves from then on.
|
||||||
- [ ] Push `main` and make it GitHub's default branch.
|
- [x] **Push `main` and make it GitHub's default branch.**
|
||||||
- [ ] Remove Renovate and CodeRabbit from the GitHub repository.
|
- [x] **Set Renovate to Silent for this repository in the Mend Developer Portal.** It then opens
|
||||||
- [ ] Add it to Gitea as a push mirror, with a GitHub credential that can write to it.
|
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] **Strip GitHub's repository to a mirror, and give both forges the package's About.** GitHub's
|
||||||
|
wiki, projects and Actions are off, the Travis app and webhook are gone, and its About matches
|
||||||
|
the package. Gitea carries the same description, website and topics, and sends issues to
|
||||||
|
GitHub as its external tracker.
|
||||||
|
|
||||||
## Close the GitHub backlog
|
## Close the GitHub backlog
|
||||||
|
|
||||||
**Answer and close as fixed by 0.5.0**, the reply naming what fixed it:
|
**Answer and close as fixed by 0.5.0**, the reply naming what fixed it:
|
||||||
|
|
||||||
- [ ] [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples:
|
- [x] **Close [#2](https://github.com/larvit/larvitsmpp/issues/2), tests for the README examples.**
|
||||||
`test/readme.test.ts`.
|
Fixed by `test/readme.test.ts`.
|
||||||
- [ ] [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages:
|
- [x] **Close [#3](https://github.com/larvit/larvitsmpp/issues/3), tests for flash messages.** Fixed
|
||||||
`test/session.test.ts`.
|
by `test/session.test.ts`.
|
||||||
- [ ] [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing:
|
- [x] **Close [#4](https://github.com/larvit/larvitsmpp/issues/4), DLR errors with `message_state`
|
||||||
`dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent.
|
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] **Close [#13](https://github.com/larvit/larvitsmpp/issues/13), limit a long SMS to fewer
|
||||||
`maxSegments` send option.
|
segments.** Fixed by the `maxSegments` send option.
|
||||||
- [ ] [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types: bound and
|
- [x] **Close [#16](https://github.com/larvit/larvitsmpp/issues/16), support all three bind types.**
|
||||||
enforced in both directions.
|
Bound and enforced in both directions.
|
||||||
- [ ] [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be
|
- [x] **Close [#17](https://github.com/larvit/larvitsmpp/issues/17), `addr_ton`/`addr_npi` should be
|
||||||
settable: `sendSms()` takes all four, documented and tested.
|
settable.** `sendSms()` takes all four, documented and tested.
|
||||||
- [ ] [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency
|
- [x] **Close [#20](https://github.com/larvit/larvitsmpp/issues/20), tests fail on current
|
||||||
versions: the mocha suite is gone; `node:test` on Node 18 to 26.
|
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] **Close [#33](https://github.com/larvit/larvitsmpp/issues/33), large inbound text arrives as
|
||||||
`Buffer` segments: `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms`
|
raw `Buffer` segments.** `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one
|
||||||
event.
|
`sms` event.
|
||||||
- [ ] [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request: `message_id` in
|
- [x] **Close [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request for `message_id`
|
||||||
`submit_sm_resp`, spec DLR codes. All four hold: `sendResp()` always answers a `message_id`,
|
in `submit_sm_resp` and spec DLR codes.** All four hold: `sendResp()` always answers a
|
||||||
per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real
|
`message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the
|
||||||
defects.
|
fork found real defects.
|
||||||
|
|
||||||
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
|
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
|
||||||
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
|
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
|
||||||
|
|
||||||
- [ ] [#40](https://github.com/larvit/larvitsmpp/pull/40),
|
- [x] **Close the twelve dependency pull requests as superseded.**
|
||||||
|
[#40](https://github.com/larvit/larvitsmpp/pull/40),
|
||||||
[#41](https://github.com/larvit/larvitsmpp/pull/41),
|
[#41](https://github.com/larvit/larvitsmpp/pull/41),
|
||||||
[#42](https://github.com/larvit/larvitsmpp/pull/42),
|
[#42](https://github.com/larvit/larvitsmpp/pull/42),
|
||||||
[#45](https://github.com/larvit/larvitsmpp/pull/45),
|
[#45](https://github.com/larvit/larvitsmpp/pull/45),
|
||||||
@@ -160,102 +175,532 @@ the push mirror replaces GitHub's branches with Gitea's, which closes every pull
|
|||||||
[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports
|
[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports
|
||||||
on the default branch; it disappears with the runtime dependencies rather than being fixed.
|
on the default branch; it disappears with the runtime dependencies rather than being fixed.
|
||||||
|
|
||||||
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — close it with
|
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard and stays open after
|
||||||
Renovate's removal from GitHub.
|
the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
|
||||||
|
|
||||||
**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and
|
**Close as tracked here**, the reply saying it will be implemented on Gitea:
|
||||||
port on log messages. Only `server - incoming connection` carries them today; putting them on every
|
|
||||||
session message is a change to every call site.
|
- [x] **Close [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and port
|
||||||
|
on log messages, as tracked here.** It sits 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, `resolveBody`'s `settles` boolean, which
|
||||||
|
the panel also ranked hardest; it is now a named `CodingSource`.
|
||||||
|
|
||||||
|
A four-seat scoring run on 2026-09-27 read #30 at 6, 6, 7 and 6, every seat capped by Locality in
|
||||||
|
the held-message and shutdown code; #30 merged under the floor on condition that Locality is the
|
||||||
|
next work ([decision](docs/decisions.md#internals-and-tests)).
|
||||||
|
|
||||||
|
### Locality — next, ahead of everything below; 5–6 today, and the gate is 7
|
||||||
|
|
||||||
|
A second four-seat run on 2026-09-28, after #35–#40, read 6, 6, 7 and 6 again, Locality 5, 5, 6
|
||||||
|
and 5. Every seat ranked the session's lifecycle hardest and least wanted to modify it.
|
||||||
|
|
||||||
|
- [ ] **Lift Locality to 7, and confirm it with a scoring run.** A run reading 7.0 or above also
|
||||||
|
retires the #30 decision. The sub-items are what the 2026-09-28 run named, most seats first.
|
||||||
|
- [ ] **Give the held-message flow one place a reader can follow it.** Whether a drain still waits
|
||||||
|
on a message is spread over `emitSms()`, `MessageHold`, the session's rejection route and
|
||||||
|
`Sms.isHeld()`. Four seats.
|
||||||
|
- [ ] **Shrink the `IncomingDeps` closure bag.** 16 lambdas, six of them repeated in
|
||||||
|
`SmsHandlers`, which makes every inbound call path indirect. Two seats.
|
||||||
|
|
||||||
|
### Correctness
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
|
|
||||||
|
- [ ] **Build every error from a thrown value through `errorFrom()`.** `reconnect-loop.ts` (in
|
||||||
|
`run()`'s catch and in `bringUp()`) and `client.ts`'s connect still call `String(thrown)`,
|
||||||
|
which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from
|
||||||
|
a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run.
|
||||||
|
|
||||||
|
- [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before
|
||||||
|
the lost-link check and before the write, so a failed answer still renames the message and a
|
||||||
|
later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run.
|
||||||
|
|
||||||
|
- [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds
|
||||||
|
`responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only,
|
||||||
|
and Node fires a larger delay after 1 ms. From the 2026-09-28 scoring run.
|
||||||
|
|
||||||
|
- [ ] **Keep a bare ESC out of GSM detection.** `gsmRegex` in `defs/encodings.ts` admits `\x1B`,
|
||||||
|
so `"\x1B("` is detected as GSM, goes out as 0x1B 0x28 and arrives as `{`. From the
|
||||||
|
2026-09-28 scoring run.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Shape — 6 today, and the gate is 7
|
||||||
|
|
||||||
|
- [ ] **Answer "is this a bind command" in one place.** `bindCommands` (read by
|
||||||
|
`incoming-requests.ts`, `outgoing-requests.ts` and `test/session.test.ts`) and
|
||||||
|
`bindTypeFromCommand()` (read by `server.ts` and `checkedBind()`) each list the three bind
|
||||||
|
commands, so a fourth added to one is missed by the other. Derive the list from the function,
|
||||||
|
or the reverse. From the stability review of #42.
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
|
|
||||||
|
- [ ] **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 three left to inline
|
||||||
|
as one line each: that `segmentUnits`' three numbers are in two units (septets and octets),
|
||||||
|
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.
|
||||||
|
|
||||||
|
- [ ] **State in README that `SmppServer.close()` reports each session's unfinished drain as
|
||||||
|
`serverError`.** Only `docs/decisions.md` says so; README's Shutdown section covers the
|
||||||
|
session's own result alone.
|
||||||
|
|
||||||
|
- [ ] **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. The gate decision in `docs/decisions.md` makes the same claim
|
||||||
|
in its title, and carries the same fix. 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
|
## Worth doing, not blocking
|
||||||
|
|
||||||
- [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in
|
- [ ] **Move the decisions out of AGENTS.md's fixtures paragraph and the two tooling READMEs.** The
|
||||||
`outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call
|
one dummy SMSC, `smscPeer()` staying separate and which copied helpers are tolerated
|
||||||
that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on
|
(AGENTS.md Conventions), and why Kannel is absent from `benchmarks/README.md`, go to
|
||||||
every attempt is the same case, and #98 made it a common one. On a down link the caller waits
|
`docs/decisions.md` with index lines. Move the planned work written into
|
||||||
`responseTimeout` and is told the link failed rather than that the body could not be built —
|
`interop-tests/AGENTS.md` (an expected count per peer) and `benchmarks/README.md` (the default
|
||||||
goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is
|
window gap) to this file. Give the `run.py`-in-background footgun in `interop-tests/AGENTS.md`
|
||||||
the open half. Raised by the architecture review of
|
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.
|
||||||
|
|
||||||
|
- [ ] **Refuse a send the codec cannot build before it waits for a link and a window slot.** Today
|
||||||
|
it waits first. `refuse()` in `outgoing-requests.ts` runs `misuse()` and the abort check
|
||||||
|
before the wait, precisely so a call that can never go out does not queue for what it will
|
||||||
|
never use; a body `objToPdu()` refuses on every attempt is the same case, and #98 made it a
|
||||||
|
common one. On a down link the caller waits `responseTimeout` and is told the link failed
|
||||||
|
rather than that the body could not be built — goal 2's wrong answer about what happened. The
|
||||||
|
cheap fix builds the PDU twice, so the shape is the open half. Raised by the architecture
|
||||||
|
review of [#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
|
||||||
|
|
||||||
|
- [ ] **Split the SMPP time format out of `message.ts` once the file has to move anyway.** It
|
||||||
|
answers two questions: message coding and the SMPP time format (`smppDate`, `smppTime`) share
|
||||||
|
the file, which the architecture map in AGENTS.md already spells out as four concerns. Nothing
|
||||||
|
is wrong today; if the file has to move for another reason, `smpp-time.ts` is the split.
|
||||||
|
Raised by the architecture review of
|
||||||
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
|
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
|
||||||
|
|
||||||
- [ ] **`message.ts` answers two questions.** Message coding and the SMPP time format (`smppDate`,
|
- [ ] **Add a gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
|
||||||
`smppTime`) share the file, which the architecture map in AGENTS.md already spells out as four
|
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml` pinning
|
||||||
concerns. Nothing is wrong today; if the file has to move for another reason, `smpp-time.ts` is
|
thread. Pinning every action and runner by hand is what the ask followed; the gate is what
|
||||||
the split. Raised by the architecture review of
|
keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and
|
||||||
[#98](https://github.com/larvit/larvitsmpp/pull/98), 2026-09-09.
|
|
||||||
|
|
||||||
- [ ] **A gate that refuses a floating version anywhere in the repo.** Maintainer's ask on
|
|
||||||
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the `release.yaml`
|
|
||||||
pinning thread. Pinning every action and runner by hand is what the ask followed; the gate is
|
|
||||||
what keeps them pinned. It has to cover workflow `uses:` and `runs-on:`, compose `image:`, and
|
|
||||||
Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the
|
Dockerfile `FROM`, and the conventions differ per kind — actions take a semver tag, images the
|
||||||
full patch version — so one grep for `latest` is not it.
|
full patch version — so one grep for `latest` is not it.
|
||||||
|
|
||||||
- [ ] **A gate that fails when the test matrix misses the current Node.** Maintainer's ask on
|
- [ ] **Add a gate that catches the test matrix missing the current Node.** Maintainer's ask on
|
||||||
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node
|
[#71](https://github.com/larvit/larvitsmpp/pull/71), 2026-09-06, on the Node 26 thread. Node
|
||||||
26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the
|
26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the
|
||||||
Node release schedule is published as JSON — and a decision on whether a new Current fails the
|
Node release schedule is published as JSON — and a decision on whether a new Current fails the
|
||||||
build or opens a PR, which is what Renovate already does for everything else here.
|
build or opens a PR, which is what Renovate already does for everything else here.
|
||||||
|
|
||||||
- [ ] **CodeRabbit reviews through the GitHub mirror.** CodeRabbit does not support Gitea, so mirror
|
- [ ] **Have CodeRabbit review Gitea pull requests through the GitHub mirror.** CodeRabbit does not
|
||||||
each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not
|
support Gitea, so mirror each Gitea pull request to GitHub for it to review there.
|
||||||
started until asked.
|
Maintainer's ask, 2026-09-14; not started until asked.
|
||||||
|
|
||||||
- [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports
|
- [ ] **Count what is left of a budget one way in `leftOf()` and the link gate.** Today they are one
|
||||||
`dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`,
|
concept counted twice. `idle-waiters.ts` reads what is left of a budget as `Math.max(1,
|
||||||
`reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that
|
deadline - now)`, because 0 means "forever" there; `link-gate.ts` runs the same subtraction
|
||||||
boundary visible. The `OutgoingRequests` extraction this was to be done with landed on
|
and calls `<= 0` expired. Neither is reachable from the other, so nothing can disagree today,
|
||||||
2026-09-01, so it is the remaining half. Raised by review, 2026-09-01.
|
but a reader who learns one and applies it to the other is wrong. A budget type both take
|
||||||
|
would close it. Raised by review, 2026-09-01.
|
||||||
|
|
||||||
- [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.**
|
- [ ] **Write a non-zero `err:` on a receipt only for a state that failed.** `receiptText()` now
|
||||||
`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.
|
|
||||||
Neither is reachable from the other, so nothing can disagree today, but a reader who learns one
|
|
||||||
and applies it to the other is wrong. A budget type both take would close it. Raised by review,
|
|
||||||
2026-09-01.
|
|
||||||
|
|
||||||
- [ ] **`err:` on a receipt for a state that neither delivered nor failed.** `receiptText()` now
|
|
||||||
writes `err:000` for `DELIVERED` and for the two transient states, and `err:001` for every
|
writes `err:000` for `DELIVERED` and for the two transient states, and `err:001` for every
|
||||||
other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the SMSC
|
other — so `ACCEPTED`, `SKIPPED`, `UNKNOWN` and `DELETED` still announce an error code the
|
||||||
never had. Which of those are failures is the open half. Raised by review, 2026-09-03; needs a
|
SMSC never had. Which of those are failures is the open half. Raised by review, 2026-09-03;
|
||||||
decision.
|
needs a decision.
|
||||||
|
|
||||||
- [ ] **`once()` is copied into four test files, and two copies never give up.**
|
- [ ] **Share one `once()` across the test files, one that gives up.** Today it is copied into four
|
||||||
`session-extras.test.ts` and `readme.test.ts` reject after 5000 ms; `session.test.ts` and
|
test files, and two copies never give up. `session-extras.test.ts` and `readme.test.ts` reject
|
||||||
`tls.test.ts` wait forever, so an event that never fires still hangs the run the way an
|
after 5000 ms; `session.test.ts` and `tls.test.ts` wait forever, so an event that never fires
|
||||||
unclosed listener used to. One shared, guarded copy closes the rest of that class.
|
still hangs the run the way an unclosed listener used to. One shared, guarded copy closes the
|
||||||
|
rest of that class.
|
||||||
|
|
||||||
- [ ] **A peer whose message ids share one base logs a refused merge on every send.** `smsc01-000123`
|
- [ ] **Carry the peer's address and bind on every session log message.** `remoteAddress` and
|
||||||
and `smsc01-000124` carry the same base, so `DlrMerger` merges the first message and refuses
|
`remotePort` reach only `server - incoming connection`, and `systemId` only the bind messages,
|
||||||
every one after it, one log line per send. Left at `info` — nothing the operator can fix is
|
so with several peers connected one session's lines cannot be told apart, and a reconnect
|
||||||
wrong — but a rate guard or silence may suit it better. Raised by review, 2026-08-30.
|
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.
|
||||||
|
|
||||||
- [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them
|
- [ ] **Settle how a peer whose message ids share one base logs its refused merges.** Today it logs
|
||||||
end to end. The interop suite is the natural place.
|
one on every send. `smsc01-000123` and `smsc01-000124` carry the same base, so `DlrMerger`
|
||||||
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript
|
merges the first message and refuses every one after it, one log line per send. Left at `info`
|
||||||
|
— nothing the operator can fix is wrong — but a rate guard or silence may suit it better.
|
||||||
|
Raised by review, 2026-08-30.
|
||||||
|
|
||||||
|
- [ ] **Exercise `submit_multi` and the broadcast commands end to end.** They encode and decode, but
|
||||||
|
nothing exercises them 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.
|
below 6.1 for exactly that reason.
|
||||||
- [ ] **Coverage reporting.** `node --test --experimental-test-coverage` works today; nothing
|
- [ ] **Add an `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on
|
||||||
publishes the numbers.
|
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 dispatch, which owns the response as well. Mirror the `onRequest` seam —
|
||||||
|
return a `Dlr` to own the receipt, `undefined` to fall through to the built-in parser.
|
||||||
|
|
||||||
- [ ] **An `onReceipt` hook.** Receipt text is only loosely specified and operators disagree on it,
|
## Gaps against other SMPP libraries
|
||||||
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
|
From comparing 0.5.0 with `smpp`, `@semyonf/smpp`, `@leissner/node-red-smpp`, `node-smpp-next`,
|
||||||
dispatch, which owns the response as well.
|
`smpp-js-sdk`, `smppjs`, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14.
|
||||||
Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through
|
Each lands under goal 7: an option or a hook, with the call that passes none unchanged.
|
||||||
to the built-in parser.
|
|
||||||
|
### Sending
|
||||||
|
|
||||||
|
- [ ] **Add 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.
|
||||||
|
|
||||||
|
- [ ] **Let `sendSms()` take 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.
|
||||||
|
|
||||||
|
- [ ] **Fail over 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
|
||||||
|
|
||||||
|
- [ ] **Send `deliver_sm` from `sendSms()` on a `server()` session.** Today it 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`).
|
||||||
|
|
||||||
|
- [ ] **Let a response this library builds carry error TLVs.** `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.
|
||||||
|
|
||||||
|
- [ ] **Accept the 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.
|
||||||
|
|
||||||
|
- [ ] **Handle `outbind`.** It is 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.
|
||||||
|
|
||||||
|
- [ ] **Carry 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.
|
||||||
|
|
||||||
|
- [ ] **Read and send the 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.
|
||||||
|
|
||||||
|
- [ ] **Offer 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.
|
||||||
|
|
||||||
|
- [ ] **Give each alphabet in `consts.ENCODING` one name.** Five are spelled 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
|
||||||
|
|
||||||
|
- [ ] **Add metrics: outbound PDU events and counts of requests waiting.** 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.
|
||||||
|
|
||||||
|
- [ ] **Add a coverage report and a floor to 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.
|
||||||
|
|
||||||
|
- [ ] **Add 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.
|
||||||
|
|
||||||
|
- [ ] **Build a Node-RED node, as a package of its own.** `@leissner/node-red-smpp` is the only SMPP
|
||||||
|
node in the Node-RED library, and by a read of its source it never parses a receipt and never
|
||||||
|
answers the SMSC's `enquire_link`. Its UI is a fair list of what operators set. It builds on
|
||||||
|
this package, never inside it.
|
||||||
|
|
||||||
## Declined
|
## Declined
|
||||||
|
|
||||||
- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call,
|
- **A check that warns before `MIRROR_GITHUB_TOKEN` expires.** Gitea mails no one about a failed
|
||||||
2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it
|
scheduled run, since its Actions bot triggers those, so a nightly check would fail unseen. The
|
||||||
already had answered, so the loss is real — but surviving it means handing the application the merge
|
maintainer relies on GitHub's own expiry reminders, and on the mirror's first failed push run after
|
||||||
state to persist, which the scope floor covers as squarely as holding the state here would, and
|
expiry, which mails whoever pushed. Maintainer's call, 2026-09-14.
|
||||||
which publishes the shape of `DlrMerger`'s groups against goal 6. Nothing is foreclosed: the seam
|
|
||||||
can still be added after 0.5.0 as a minor.
|
|
||||||
|
|
||||||
- **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md
|
- **CommonJS.** ESM only, maintainer's call reaffirmed 2026-09-14, though `node-smpp-next` ships both.
|
||||||
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns
|
`require()` of an ES module works unflagged from Node 20.19 and 22.12.
|
||||||
is a session, so a bucket here cannot see a second process binding the same account and is wrong in
|
|
||||||
exactly the case it exists for. `sendSms()` surfaces `ESME_RTHROTTLED` to the caller instead, and
|
## An optional store
|
||||||
`maxOutstanding` stays — a window slot frees on the peer's next response, which is self-limiting in
|
|
||||||
a way a rate ceiling is not.
|
- [ ] **Add 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"],
|
"types": ["node"],
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true
|
||||||
},
|
},
|
||||||
"include": ["eslint.config.ts", "interop-tests", "src", "test"]
|
"include": ["benchmarks", "eslint.config.ts", "interop-tests", "src", "test"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user