Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1c2c2fb76 | |||
| be0f2c33c9 | |||
| bf1bddba3b | |||
| 92479d697d | |||
| 980e9d4569 | |||
| 108eb1e5b2 | |||
| d9da3cebab | |||
| 576b713af4 | |||
| 1b075bf5d2 | |||
| 57f53d56f1 | |||
| 8721491cd5 | |||
| fd3b08435a | |||
| 0bea36c069 | |||
| 1aadcbe3c9 | |||
| 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 |
@@ -7,7 +7,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# No concurrency group: Gitea cancels a queued run when the next one in its group arrives, dropping the delete.
|
|
||||||
delete:
|
delete:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
push:
|
push:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
# Gitea 1.26 rolls back a run whose jobs share a group, so the delete job lives in mirror-delete.yaml.
|
# Gitea cancels a queued job in this group when the next one arrives; only the full push may join.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: mirror
|
group: mirror
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 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.
|
||||||
|
- The `SmsInput` type is no longer exported; nothing exported took one. Annotate with `Sms`, or a
|
||||||
|
`Pick<Sms, …>` of the fields you use.
|
||||||
|
- `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).
|
||||||
+15
-2
@@ -1,6 +1,6 @@
|
|||||||
# Migrating from larvitsmpp 0.4.0
|
# Migrating from larvitsmpp 0.4.0
|
||||||
|
|
||||||
`@larvit/smpp` 0.5.0 succeeds [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The
|
`@larvit/smpp` succeeds [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The
|
||||||
shape is the same, connect, send, listen for delivery reports, with callbacks replaced by promises.
|
shape is the same, connect, send, listen for delivery reports, with callbacks replaced by promises.
|
||||||
|
|
||||||
## API changes
|
## API changes
|
||||||
@@ -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,93 @@ 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.** 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. State shared
|
||||||
|
between instances is for goal 9's store, which has not shipped: today every session keeps its own,
|
||||||
|
in memory.
|
||||||
|
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks.
|
||||||
|
|
||||||
|
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 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/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/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,315 @@
|
|||||||
|
# Board on the three rewrite plans
|
||||||
|
|
||||||
|
## Junior seat
|
||||||
|
|
||||||
|
**Junior seat** (about 2 years of TypeScript, no SMPP)
|
||||||
|
|
||||||
|
My read of main: `link-life.ts` has 7 predicates over a phase plus a separate `stopped` flag, and the initial `'up'` breaks its own rule. `expiring-groups.ts` has a header that says what it does *not* enforce. `session.ts` routes `captureRejections` for `sms` into `incoming.listenerRejected`. The README's "Receive SMS" section tells me to call `sendResp()` on a multipart message that "puts nothing on the wire". I agree with 6 overall and Locality 5.
|
||||||
|
|
||||||
|
## Plan 1: folders follow the README
|
||||||
|
1. **Would it help? Marginal, close to yes.** Folders named after the README sections are the first layout I could find things in without asking. The problem is answering: I can answer with `sendResp()`, by returning, or by throwing, and `answeredOnArrival` is still there. That is D's "answered in three places" again, only now behind `OwedAnswer`.
|
||||||
|
2. **Predicted scores**
|
||||||
|
- Nav 7: README section maps to folder.
|
||||||
|
- Loc 6: the answer path spans `receive-message.ts`, `running-handlers.ts`, `owed-answer.ts` and `sms.ts`.
|
||||||
|
- Shape 6: the `AnswerPort`/`SendPort` ports are indirection I have to learn.
|
||||||
|
- Self 6: citations get summaries, but the glossary sits in the README, and lessons.md says that did not lift juniors.
|
||||||
|
- Overall 6.
|
||||||
|
3. **Where I would still get stuck:** `receiving/running-handlers.ts` together with `session/owed-answer.ts`. What happens when `sendResp()` is called and the handler then throws?
|
||||||
|
4. **Wrong or vague**
|
||||||
|
- The "two SmsSenders" question is left open until chunk 6.
|
||||||
|
- `client()` returns a `ClientSession` but still calls it `session`, and `sock`/`sendReturn` move to `session.link`. I would not know which object to listen on.
|
||||||
|
- Keeping `sendResp()` next to "return answers" gives two ways to answer.
|
||||||
|
- The no-handler refusal turns a transceiver that never wanted inbound messages into an endless SMSC retry loop.
|
||||||
|
|
||||||
|
## Plan 2: state ownership first
|
||||||
|
1. **Would it help? Marginal.** It keeps one public `Session` over many internal `Link`s, which brings back the thing F removed: the lifecycle is still split over two fields. It adds `LinkOwner`, five callbacks implemented in another file, which is E's lesson again. `session.ts` stays the hub for handover, link-wait, reconnect and the merger.
|
||||||
|
2. **Predicted scores**
|
||||||
|
- Nav 6: the Session versus Link vocabulary. `sms.ts` sits in `session/` while `handlers.ts` sits in `link/`.
|
||||||
|
- Loc 6: there is one answer ledger, but handover is split across `adopt`/`onGone` and the callbacks.
|
||||||
|
- Shape 6: four request lanes are still four rules.
|
||||||
|
- Self 6: `wire/fields.ts` helps, but the glossary sits away from the code in `docs/`.
|
||||||
|
- Overall 6.
|
||||||
|
3. **Where I would still get stuck:** `session/session.ts` handover together with `link/link.ts`'s `LinkOwner`.
|
||||||
|
4. **Wrong or vague**
|
||||||
|
- This plan has the smallest API break of the three, and that is the best part for an application developer.
|
||||||
|
- The `sendDlr`-before-answer error will surprise anyone writing a test SMSC.
|
||||||
|
- The reassembly store refusing its own entry is a behaviour change with no decision written yet.
|
||||||
|
|
||||||
|
## Plan 3: the newcomer's lens
|
||||||
|
1. **Would it help? Yes.** `protocol/vocabulary.ts` puts TSDoc on hover, and all bit masks stay inside `protocol/`. `data-coding.ts` becomes a table with a "why" column, and a test fails on any bare citation. That attacks the Self-sufficiency 5 that capped juniors. `Reply` as a return value makes "one answer" a type.
|
||||||
|
2. **Predicted scores**
|
||||||
|
- Nav 7: protocol, codec, messages, session is a reading order.
|
||||||
|
- Loc 6: `client/client.ts` gathers the current session, the merge, the reference counter, the retry loop, re-emitting, `fromStart` and abort.
|
||||||
|
- Shape 7: forward-only session, a store that refuses, `Reply` as the one path.
|
||||||
|
- Self 7: definitions at the point of use.
|
||||||
|
- Overall 6, one refactor short of 7.
|
||||||
|
3. **Where I would still get stuck:** the request loop and event re-emitting in `client/client.ts`. The plan itself predicts this.
|
||||||
|
4. **Wrong or vague**
|
||||||
|
- It has six breaks. A1 renames `{ session }` to `{ client }`, which breaks every README example for no comprehension gain beyond F, which scored the same as main.
|
||||||
|
- A3's `Reply.dlr` is a second way to send a receipt next to `sendDlr()`.
|
||||||
|
- A4 removes `sendReturn()`, the escape hatch for hand-wired users.
|
||||||
|
- `handlerTimeout` appears in `handlers.ts` but is missing from the API table.
|
||||||
|
- Refuse-not-evict can block multipart traffic for `reassemblyTimeout`, which regresses goal 4.
|
||||||
|
- `waiting.ts` extracts the abort dance, contradicting a recorded decision without saying so.
|
||||||
|
- A6 (`'ASCII'` to `'GSM7'`) is justified for me as a reader, but it is optional.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
- **Ranking:** Plan 3, Plan 1, Plan 2.
|
||||||
|
- **Can the best reach 7?** Plausibly, about a coin flip. The single change that most raises its odds: move the retry loop and the link-wait out of `client/client.ts` into their own file, `client/next-link.ts` as in Plan 1, so `SmppClient` only holds and forwards. Dropping A1 and A3 would also stop the application-developer complaints from costing Shape.
|
||||||
|
- **Structure or intrinsic difficulty?** Mostly structure. Each round moved the hardness around while the SMPP-to-plain-types translation was never in one place. The truly intrinsic parts are small and can be localized: per-segment answers on arrival, the drain's two budgets, and retrying only what was never written. For a junior, the rest of the ceiling was missing domain vocabulary. That is a structural choice about where meaning lives, not a property of the problem.
|
||||||
|
|
||||||
|
PLAN1 helps=marginal nav=7 loc=6 shape=6 self=6 overall=6
|
||||||
|
PLAN2 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6
|
||||||
|
PLAN3 helps=yes nav=7 loc=6 shape=7 self=7 overall=6
|
||||||
|
|
||||||
|
## Mid seat
|
||||||
|
|
||||||
|
**Mid seat (5 years TypeScript, never read the SMPP spec)**
|
||||||
|
|
||||||
|
My view of main today: 6. I can find my way around the flat `src/`, and `session.ts` is readable at the top. `LinkLife` is where I stall. It has a phase, a `stopped` flag, seven predicates and an initial `'up'` that breaks its own rule. `ExpiringGroups` is the other place: its doc comment says it enforces neither of its own bounds, yet `weigh()` can evict the key the caller is writing. The `captureRejections` routing to `incoming.listenerRejected` also takes me three files to follow.
|
||||||
|
|
||||||
|
## Plan 1: README verbs as folders, a one-socket Session, and ClientSession
|
||||||
|
|
||||||
|
1. **Helps? Marginal, leaning yes.** Folders that mirror the README's table of contents are what I would guess first. `owed-answer.ts` gives "one answer per request" a single writer, and `BoundedStore` stops evicting the caller's own entry. But the one-socket split is F's, which already scored 6.25, and the plan leaves its sharpest follow-up open: which object owns `SmsSender`, to be settled "at chunk 6".
|
||||||
|
2. **Predicted scores:**
|
||||||
|
- Navigation 7: the folder names are the README sections.
|
||||||
|
- Locality 6: an inbound message still crosses `dispatch.ts`, `receive-message.ts`, `running-handlers.ts`, `owed-answer.ts` and `sms.ts`.
|
||||||
|
- Shape 6: `ClientSession` forwards events, and there are two `SmsSender`s and two ports.
|
||||||
|
- Self-sufficiency 7: the README glossary, citations with their summaries, and `Invariant:` paragraphs.
|
||||||
|
- Overall 6.
|
||||||
|
3. **What would still defeat me:** `client/client.ts`. A `ClientSession` that re-emits the current `Session`'s events, answers `boundAs` through the reconnect gap, and holds a merger fed from the link's `dlr`. It is the "which object do I listen on" problem moved up one layer.
|
||||||
|
4. **Wrong or vague:**
|
||||||
|
- `client()` still resolves `{ session }`, but the value is a `ClientSession` and `session.link` is the real `Session`. That name misleads an application developer.
|
||||||
|
- `sendResp()` is kept, and returning from the handler also answers. That is two ways to answer the same message, and `answeredOnArrival` stays as a third concept.
|
||||||
|
- `limits/` is an abstraction name I would not look in for the send window.
|
||||||
|
- The dual `SmsSender` is undecided.
|
||||||
|
|
||||||
|
## Plan 2: state ownership, with a Link inside the public Session
|
||||||
|
|
||||||
|
1. **Helps? Marginal.** The ownership rules are right: one writer, a lifetime `AbortController`, and "emit last". `wire/fields.ts` naming the bit masks helps me more than any glossary. But `Session` still spans many links, which is the unit that capped round two, now split across `session.ts` and `link.ts`. They are joined by `LinkOwner`, a five-callback interface, which is exactly what defeated E.
|
||||||
|
2. **Predicted scores:**
|
||||||
|
- Navigation 6: `link/` against `session/` is a distinction I must learn before I can place `handlers.ts`, `sms.ts` or `send-sms.ts`.
|
||||||
|
- Locality 6: the answering path is in two adjacent files, which is good, but a link going down spans `Link`, `LinkOwner`, `adopt`/`onGone`, `link-wait.ts` and `outbound.ts`.
|
||||||
|
- Shape 6: four lanes in one table are still four rules. `sock` means "the current or last Link's", and `boundAs` is read "from the last bound Link".
|
||||||
|
- Self-sufficiency 6: the glossary lives in `docs/glossary.md`, away from the code, and the lessons say an off-code glossary did not lift juniors.
|
||||||
|
- Overall 6.
|
||||||
|
3. **What would still defeat me:** `session/session.ts` `adopt`/`onGone`, with `session/outbound.ts`'s retry loop that goes back to `boundLink()`. That is the reconnect lifecycle again, under new names.
|
||||||
|
4. **Wrong or vague:**
|
||||||
|
- It keeps the reconnecting `Session` to avoid F's rename. That preserves the exact structure every round-two seat named.
|
||||||
|
- Changing `sendReturn()` to return `err` in two new cases is a silent behaviour change on an existing call.
|
||||||
|
- The rule "a `sendDlr()` before the answer returns `err`" breaks test-double servers that report immediately, and the plan admits it.
|
||||||
|
- The five callbacks and the lane table are not specified.
|
||||||
|
|
||||||
|
## Plan 3: a `protocol/` translation layer, `Reply` return values, and SmppClient
|
||||||
|
|
||||||
|
1. **Helps? Yes.** This is the only plan aimed at my actual cost: the bit masks and SMPP terms, translated once in `protocol/` into typed plain values, with definitions I see on hover in the editor. A citation test enforces that every spec reference carries its sentence. `requests-in.ts replyFor()` is a pure function returning a `Reply`, which makes "one answer per request" a type instead of an agreement between files. Its `Session` is one socket with one forward-only state field.
|
||||||
|
2. **Predicted scores:**
|
||||||
|
- Navigation 7: `codec`, `protocol`, `messages`, `session`, `client`, `server` read in order. The weak spots are `retained.ts` and `bounded-store.ts` under `messages/`.
|
||||||
|
- Locality 7: a reply is decided in one function and written in one place, and data_coding is one table.
|
||||||
|
- Shape 6: two `sendSms` surfaces, `Reply.dlr` beside `sendDlr()`, and a `SmppClient` hub.
|
||||||
|
- Self-sufficiency 7: definitions next to their use, and every citation says what it cites.
|
||||||
|
- Overall 7.
|
||||||
|
3. **What would still defeat me:** `client/client.ts`. It holds the current session, the receipt merge, the concatenation reference counter, the retry-if-unwritten request loop, event re-emission, `close`/`unbind`, and `fromStart` plus abort. That is seven responsibilities in one file, and the plan itself predicts it becomes the next hardest unit.
|
||||||
|
4. **Wrong or vague:**
|
||||||
|
- Six breaking changes is more than "a minimum". A6 (`'ASCII'` renamed to `'GSM7'`) breaks every caller that set an encoding. It is justified by one-spelling-per-goal but not required for comprehension, since the internal rename already gets that gain.
|
||||||
|
- A3's `Reply.dlr` is a second way to send a receipt.
|
||||||
|
- Refusing new segments when the reassembly store is full, instead of evicting the oldest group, lets abandoned groups block all multipart traffic until `reassemblyTimeout`. That hurts an operator (goal 4), and the plan does not weigh it against the goal 2 gain.
|
||||||
|
- Removing `sendReturn()` takes away an escape hatch for low-level users without saying what replaces it beyond "return a `Reply`".
|
||||||
|
- `retained.ts` sits in `messages/` only because the stores use it. That is proximity, not a real seam.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Ranking:** Plan 3, then Plan 1, then Plan 2.
|
||||||
|
|
||||||
|
**Can the best reach 7 overall?** Plausibly on my seat. A panel mean of 7 is less likely, because the architect seat will cite the `SmppClient` hub and the number of API breaks. The single change that would most raise its odds is to split `SmppClient`'s request loop into its own file (`client/next-link.ts`, as in Plan 1), owning waiting-for-a-bound-session and retry-only-unwritten with its invariant. `client.ts` then only composes, and the predicted next hardest unit never forms.
|
||||||
|
|
||||||
|
**Structure or intrinsic difficulty?** Mostly structure, and specifically the contract that structure was built around. Each round, the named hardest unit was self-inflicted rather than SMPP:
|
||||||
|
- the held-message timing contract;
|
||||||
|
- a reconnecting session with duplicated stop flags;
|
||||||
|
- a store that does not enforce its own bounds;
|
||||||
|
- answering enforced jointly by two files.
|
||||||
|
|
||||||
|
When the contract changed, the unit moved, and none of the ones named since are protocol facts. The intrinsic part — multipart answered on arrival, the drain's two budgets, retrying only what never reached the socket, and data_coding groups — is real. But it is a handful of localized items, which puts the ceiling near 7–8, not 6. The earlier redesigns stalled because each one left a different piece of shared, unowned state behind.
|
||||||
|
|
||||||
|
PLAN1 helps=marginal nav=7 loc=6 shape=6 self=7 overall=6
|
||||||
|
PLAN2 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6
|
||||||
|
PLAN3 helps=yes nav=7 loc=7 shape=6 self=7 overall=7
|
||||||
|
|
||||||
|
## Senior seat
|
||||||
|
|
||||||
|
Plan 3 is the only one I expect to reach 7. Plan 1 is a marginal gain and plan 2 is roughly main plus renames. My seat most likely already gave main its 7, so a rewrite has to beat 7 on this seat to count as help here.
|
||||||
|
|
||||||
|
**How hard main is today, from my seat.** `link-life.ts` shows lesson 3 exactly. It has a 4-value phase starting at `'up'`, a separate `stopped`, and seven predicates. Its `generation()` counter exists only so a reader can tell a link has gone. `session.ts` (453 lines) wires ten collaborators. Its `captureRejectionSymbol` override reaches into `incoming.listenerRejected`. The layout is flat and named well, so Navigation is fine. The cost is in Locality: to know whether a send is safe you need `LinkLife`, `OutgoingRequests` and `Session` open together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan 1: verb folders, one-socket `Session`, `ClientSession` above it, `onSms` plus `sendResp` kept
|
||||||
|
|
||||||
|
**1. Would it help? Marginal.**
|
||||||
|
- The one-way `Session`, the reconnect union state, `BoundedStore` enforcing its own bounds and one defaults file all remove units the panels named.
|
||||||
|
- It adds new sources of confusion in their place:
|
||||||
|
- `client()` still returns a field called `session` that is a `ClientSession`, and `session.link` is a `Session`. Two names now lie.
|
||||||
|
- Answering has two spellings. The handler can call `sendResp()` or just return, and the return path does "answer `ESME_ROK` if not answered". That is D's "answered in several places" again, now as a getter over OwedAnswers.
|
||||||
|
- The answer path spans five files in two folders: `dispatch.ts`, `owed-answer.ts`, `receive-message.ts`, `running-handlers.ts` and `sms.ts`.
|
||||||
|
|
||||||
|
**2. Predicted scores**
|
||||||
|
- **Navigation 7.** The README table of contents mirrors the folder listing, which makes the layout easy to find your way around. `limits/` is the one abstract name.
|
||||||
|
- **Locality 6.** The one-answer invariant has one writer, but you cannot understand it without the other four files. `AnswerPort` and `SendPort` add a hop.
|
||||||
|
- **Shape 6.** `ClientSession` forwards a long event list. Whether there is one `SmsSender` or two is left open.
|
||||||
|
- **Self-sufficiency 7.** Every citation gets a summary, the README gets a glossary, and there is a data-coding table.
|
||||||
|
- **Overall 7**, one point above the lowest dimension (Locality 6), which is the most the rule allows. It is no better than main on this seat.
|
||||||
|
|
||||||
|
**3. Where I would still get stuck.** `client/client.ts` together with `client/next-link.ts`: `ClientSession` forwarding events from whichever `Session` is current, plus answering `boundAs` through the reconnect gap. Second place: `session/owed-answer.ts` together with `receiving/running-handlers.ts`.
|
||||||
|
|
||||||
|
**4. Wrong or vague**
|
||||||
|
- `SmsSender` is left as "pick one at chunk 6". That is an ownership question, and the plan's own principle says ownership comes first.
|
||||||
|
- Returning `ClientSession` under the name `session` is a public API that misleads the developer about what they hold. Either rename it honestly, as plan 3 does, or keep a single `Session`.
|
||||||
|
- Keeping `sendResp()` alongside the implicit return answer gives two ways to answer, against the one-spelling rule.
|
||||||
|
- `linkEnd` becoming readonly is justified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan 2: state ownership, a `Link` per socket behind the unchanged public `Session`
|
||||||
|
|
||||||
|
**1. Would it help? Marginal.**
|
||||||
|
- The strongest ownership rules of the three: one `AbortController` as the single writer of stoppedness, and "a transition finishes before anyone hears about it".
|
||||||
|
- The smallest API break, with a single answering spelling (`onSms` returning `{smsId}`/`{status}`) and a ledger that refuses a second answer.
|
||||||
|
- But its structure is draft A (a `Link` object per socket) plus E's weakness (`LinkOwner`, five callbacks implemented in another file). Lesson 3 of the round-three notes already says E's state machine stayed hard because meaning was split across callbacks.
|
||||||
|
- `session.ts` stays the hub: life, current link, handover, link-wait, window, reconnect, merger.
|
||||||
|
- `outbound.ts` keeps four "lanes", which were already cited in round 2.
|
||||||
|
|
||||||
|
**2. Predicted scores**
|
||||||
|
- **Navigation 6.** Readers must learn the Session/Link split. `link/` holds handlers and reassembly, which a reader would not look for there.
|
||||||
|
- **Locality 6.** A handover is `adopt`/`onGone` in `session.ts` plus the `LinkOwner` callbacks in `link.ts`. Each transition's meaning is split across two files.
|
||||||
|
- **Shape 6.** Four lanes, a hub `Session`, and a new "refuse own entry" eviction policy.
|
||||||
|
- **Self-sufficiency 7.** `wire/fields.ts` names the bit masks, and every citation gets its sentence.
|
||||||
|
- **Overall 6.**
|
||||||
|
|
||||||
|
**3. Where I would still get stuck.** `session/session.ts` (`adopt`/`onGone` and the link handover) read against `link/link.ts`'s `LinkOwner`. Second place: the lane table in `session/outbound.ts`.
|
||||||
|
|
||||||
|
**4. Wrong or vague**
|
||||||
|
- `sendDlr()` returning `err` before the answer is a trap for test SMSCs that report immediately. The plan admits this and gives only a README pattern as the fix.
|
||||||
|
- The own-entry refusal changes reassembly behaviour under pressure without a stated goal trade-off.
|
||||||
|
- It is unclear where `idle-waiters` ends up: the plan says "inlined" in two places.
|
||||||
|
- It never says whether `generation()`'s replacement ("a message holds its Link") keeps a gone `Link` alive in memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan 3: a `protocol/` translation layer, answers as `Reply` return values, `SmppClient` above a one-socket `Session`
|
||||||
|
|
||||||
|
**1. Would it help? Yes.**
|
||||||
|
- It is the only plan that makes "one answer per request" a type. `replyFor()` returns a `Reply`, and `Session.write()` is the single writer. `onRequest` also returns a `Reply`, and `sendReturn` is gone.
|
||||||
|
- Bit masks never leave `protocol/`, and a test fails on any bare `§x.y.z` citation. That targets the junior Self-sufficiency cap directly.
|
||||||
|
- `BoundedStore` is kept simple.
|
||||||
|
- It removes the lifecycle cap the same way F did.
|
||||||
|
|
||||||
|
**2. Predicted scores**
|
||||||
|
- **Navigation 7.** The areas read in order: protocol → codec → messages → session → client. The only open question is which `sendSms` to call.
|
||||||
|
- **Locality 7.** Answering, the lifecycle and the drain each live in one function. The exception is the `client.ts` hub.
|
||||||
|
- **Shape 7.** State sits in three named files and reconnect lives above the socket. `Reply.dlr` is a wart.
|
||||||
|
- **Self-sufficiency 7.** The glossary is in code, shown on hover, and citations are enforced by a test. `field-types.ts` stays dense.
|
||||||
|
- **Overall 7.**
|
||||||
|
|
||||||
|
**3. Where I would still get stuck.** `client/client.ts`. It holds the current session, the receipt merge, the segment reference counter, the request loop that retries only unwritten requests, event re-emitting, `close`/`unbind` and `fromStart` plus abort. That is F's `SmppClient` with more loaded onto it, and the lessons predict the hardest unit lands here next.
|
||||||
|
|
||||||
|
**4. Wrong or vague**
|
||||||
|
- **The async path is not described.** `replyFor()` is described as pure, but `onSms` is asynchronous. The plan never names the one function that carries a message from `replyFor` through `handlers.ts` to `session.write`. Without it, "exactly one answer" spreads back over three files.
|
||||||
|
- **A6 (`'ASCII'` renamed to `'GSM7'`) is unjustified.** It breaks every caller for a name the code can gloss once, and goal 8 favours a stable surface.
|
||||||
|
- **A3 (`Reply.dlr`) adds a second way to send a receipt** beside `sms.sendDlr()`.
|
||||||
|
- **Refuse-not-evict for reassembly** lets a peer that abandons segment groups block all multipart traffic for `reassemblyTimeout`. That is an operator-facing regression under goal 4, and "goal 2 served better" does not hold for traffic we refuse and the peer then gives up on.
|
||||||
|
- **A1 renames `session` to `client`** on `client()`'s result. That is defensible: it is honest where plan 1 is not, but it is a real migration cost.
|
||||||
|
- It is silent on goal 9 (a store interface). `BoundedStore`'s shape should not make that goal harder later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Ranking:** plan 3, then plan 1, then plan 2.
|
||||||
|
|
||||||
|
**Can the best reach 7 overall?** Plausibly yes, as a mean around 6.75 to 7, if it drops A3 and A6. The single change that would most raise its odds: move the retry-only-unwritten request loop out of `client/client.ts` into its own file, like plan 1's `client/next-link.ts`, with its invariant at the top. The same file or function should also carry the async `onSms` reply path, so that neither `SmppClient` nor the answer path becomes the next hardest unit.
|
||||||
|
|
||||||
|
**Structure or intrinsic difficulty?** Mostly structure.
|
||||||
|
- The hardest unit moved every round: the held-message flow, then the lifecycle, then `ExpiringGroups` and the answer invariant. Intrinsic difficulty does not move when you reorganise, so a cap that moves each time is coming from coupling.
|
||||||
|
- Every draft so far kept at least one object that held both the socket and what outlives the socket, or both the answer and its trigger. The panel scores that worst unit.
|
||||||
|
- The intrinsic core does set a floor: answering each segment on arrival, the drain's two budgets, retrying only what was never written, and `data_coding`. That floor is about 7 and is not what capped the drafts at 6.
|
||||||
|
- The coarse four-seat integer scale explains why every drop in difficulty looked like no movement at all.
|
||||||
|
|
||||||
|
PLAN1 helps=marginal nav=7 loc=6 shape=6 self=7 overall=7
|
||||||
|
PLAN2 helps=marginal nav=6 loc=6 shape=6 self=7 overall=6
|
||||||
|
PLAN3 helps=yes nav=7 loc=7 shape=7 self=7 overall=7
|
||||||
|
|
||||||
|
## Architect seat
|
||||||
|
|
||||||
|
**Board seat: inherited architect.** I read `link-life.ts` (a four-value phase plus `stopped`, seven predicates, and an initial `'up'`), `expiring-groups.ts` (which says outright that it "enforces neither max nor timeout itself") and `session.ts`. My view matches the panel: 6 overall, Locality 6. The hard spots are ones the code created, not ones SMPP forces.
|
||||||
|
|
||||||
|
## Plan 1: folders named after what the developer does, `ClientSession` above a one-socket `Session`
|
||||||
|
|
||||||
|
1. **Would it help?** Marginal. It combines F's one-socket split with D's handler that keeps `sendResp()`, and both scored 6 before. Answering is now spread over four files in two folders: `session/owed-answer.ts`, `receiving/receive-message.ts`, `receiving/running-handlers.ts` and `receiving/sms.ts`. The ports (`AnswerPort`, `SendPort`) add names without removing a step.
|
||||||
|
2. **Predicted scores:**
|
||||||
|
- Nav 6: folders named after what the developer does are good. But `client()` still resolves `{ session }`, and that value is a `ClientSession` whose `.link` is the real `Session`, so the name lies at the first line of every example. `limits/` is a catch-all name.
|
||||||
|
- Loc 6: the one-answer rule has one writer, but the path to that writer crosses two folders through ports.
|
||||||
|
- Shape 6: `sendResp()` and returning from the handler are two ways to answer. "Two `SmsSender`s in client mode" is left unresolved.
|
||||||
|
- Self 6: the glossary goes in the README, which the lessons say did not lift juniors.
|
||||||
|
- Overall 6.
|
||||||
|
3. **Where it still defeats me:** `client/client.ts`. It re-emits the current link's events, answers `boundAs` through the reconnect gap and owns the merger across links, while `client/next-link.ts` retries underneath it. This is F's `SmppClient` again.
|
||||||
|
4. **Wrong or vague:**
|
||||||
|
- Keeping the name `session` for a `ClientSession` is harmful. An app developer calls `session.sock` or `session.sendReturn()` and finds them moved to `session.link`.
|
||||||
|
- Which object owns the `SmsSender` is left open "until chunk 6", and that is the part most likely to rot.
|
||||||
|
- `sendResp()` plus answer-on-return breaks the one-spelling rule.
|
||||||
|
- Making `linkEnd` readonly is fine.
|
||||||
|
|
||||||
|
## Plan 2: every piece of state has one owner, a private `Link` per socket behind the public `Session`
|
||||||
|
|
||||||
|
1. **Would it help?** Marginal, leaning yes. The ownership discipline is the most honest of the three: a phase that only moves forward, one `AbortController` as the only stop signal, and `link/answers.ts` refusing a second answer. But `Session` stays the hub (current link, link wait, send window, reconnect, merger, handover). `Link` reports to it through a five-callback `LinkOwner`, which is the same shape E's panel called hard. The retry still spans links, in `session/outbound.ts` with four lanes.
|
||||||
|
2. **Predicted scores:**
|
||||||
|
- Nav 6: a `Session` versus `Link` vocabulary gap. Held messages and reassembly under `link/` surprise a reader who thinks of them as message concerns.
|
||||||
|
- Loc 6: a transition's meaning is split between `link.ts` and the `LinkOwner` callbacks implemented in `session.ts`.
|
||||||
|
- Shape 7: one writer per piece of state, and invariants stated at their owner.
|
||||||
|
- Self 6: `wire/fields.ts` names the bit masks, but the glossary sits in `docs/`, away from where the terms are used.
|
||||||
|
- Overall 6.
|
||||||
|
3. **Where it still defeats me:** `session/session.ts`, in `adopt()`/`onGone()` together with `session/outbound.ts`'s loop over `boundLink()`. That is the reconnect lifecycle kept inside the public unit, only renamed.
|
||||||
|
4. **Wrong or vague:**
|
||||||
|
- It keeps reconnect inside `Session`, which the lessons show is where the ceiling sits. The plan says the rename to `SmppClient` "bought nothing a reader scores", but F's gain was exactly the removal of the lifecycle from the list of hardest units.
|
||||||
|
- The four lanes stay four rules.
|
||||||
|
- Refusing the incoming segment when a store is full is a behaviour change under pressure, and the plan argues it only as a risk.
|
||||||
|
- The API changes are the most conservative: `sendResp()` becomes the handler's return value, `sendDlr()` before the answer returns `err`, and a double `sendReturn()` returns `err`. All are justified.
|
||||||
|
|
||||||
|
## Plan 3: plain-English protocol types, answers as return values, `SmppClient` above a one-socket `Session`
|
||||||
|
|
||||||
|
1. **Would it help?** Yes. It is the only plan that attacks all three standing caps at once:
|
||||||
|
- **Lifecycle:** one socket per `Session`, with reconnect in `SmppClient`.
|
||||||
|
- **Answering:** an answer is a `Reply` value, `requests-in.ts` is a pure function, and `session.write()` is the single writer. "Exactly one answer" becomes something the type system enforces.
|
||||||
|
- **Self-sufficiency:** `protocol/vocabulary.ts` gives definitions on hover, `data-coding.ts` becomes a table checked against today's code on all 256 values, and a test fails on any bare spec citation. Of the three, this is the one that actually changes a junior's Self-sufficiency.
|
||||||
|
2. **Predicted scores:**
|
||||||
|
- Nav 7: the folder order (`protocol` → `codec` → `messages` → `session` → `client`) tells you where a question is answered.
|
||||||
|
- Loc 6: `client/client.ts` gathers the current session, state kept across links, the retry loop, event re-emitting, `fromStart` and abort in one file.
|
||||||
|
- Shape 7: pure `replyFor()`, a store that refuses rather than evicts and never mutates on read, and a lifecycle that only moves forward.
|
||||||
|
- Self 7: vocabulary beside the code, and the citation rule enforced by a test.
|
||||||
|
- Overall 7, but only just.
|
||||||
|
3. **Where it still defeats me:** `client/client.ts` (`SmppClient`). The request loop waits for a bound session across reconnects, retries only what was never written, and re-emits events, all next to the `fromStart` and abort handling. The plan's own risk list predicts this file. `session/handlers.ts` converting a handler's outcome into a `Reply` for a message already answered on arrival comes second.
|
||||||
|
4. **Wrong or vague:**
|
||||||
|
- Six breaking changes where two carry the value.
|
||||||
|
- **A3** (`Reply.dlr`) is a second way to send a receipt beside `sendDlr()`. It is unjustified; drop it.
|
||||||
|
- **A6** (`'ASCII'` → `'GSM7'`) breaks every caller for a name that can be glossed once inside the library. Drop it.
|
||||||
|
- **A1** (`{ client }` in place of `{ session }`) is justified, and it is more honest than plan 1's `session` that is really a client.
|
||||||
|
- Refuse-not-evict means a peer that abandons segment groups blocks all new multipart traffic for `reassemblyTimeout`. The goal 4 regression is argued only in one direction.
|
||||||
|
- `retained.ts` and `bounded-store.ts` in `messages/` are misfiled, since neither is message logic.
|
||||||
|
- "Re-emits session events" does not say which events or how.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Ranking:** Plan 3, then Plan 2, then Plan 1.
|
||||||
|
|
||||||
|
**Does plan 3 reach 7?** Plausibly. My odds are about even, because Locality 6 is the cap and the overall may not exceed it by more than one. The single change that most raises the odds: move `SmppClient`'s bound-session wait and unwritten-only retry out of `client/client.ts` into a file of their own (plan 1's `client/next-link.ts` shape) with its `Invariant:` paragraph. `client.ts` is then only composition and state carried across links, and the unit the panel will name next is small and marked. Dropping A3 comes second.
|
||||||
|
|
||||||
|
**Structure or intrinsic difficulty?** Structure, including the structure the public contract forced. Every unit the rounds named was one the code created, not the protocol:
|
||||||
|
- the held-message timing contract;
|
||||||
|
- `LinkLife`'s predicates and its duplicate stop flag;
|
||||||
|
- `ExpiringGroups` leaving its bounds to its callers;
|
||||||
|
- one answer enforced jointly by two files.
|
||||||
|
|
||||||
|
The truly hard SMPP parts are few and can be kept in one place each: segments answered on arrival, the drain's two budgets, retrying only what never reached the socket, `data_coding` groups and operator receipt spellings. A 7 allows exactly that. The first two rounds failed because internals were rearranged under a contract that pinned the hard spot in place. The later rounds each removed a created unit and exposed the next one. Nothing yet shows that SMPP itself caps the scores at 6.
|
||||||
|
|
||||||
|
PLAN1 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6
|
||||||
|
PLAN2 helps=marginal nav=6 loc=6 shape=7 self=6 overall=6
|
||||||
|
PLAN3 helps=yes nav=7 loc=6 shape=7 self=7 overall=7
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
# Lessons from three redesign rounds
|
||||||
|
|
||||||
|
A four-seat comprehension panel reads the whole project: a junior, a mid, a maintainability senior and an inherited-system architect. It scores on an absolute 1–10 scale, where 7 = "Predictable: the layout answers where things live; the hard parts are hard because the problem is hard, few, localized and marked". There are four dimensions: Navigation, Locality, Shape and Self-sufficiency. The overall may not exceed the lowest dimension plus one. The target is a mean overall at least one full point above main.
|
||||||
|
|
||||||
|
| | Overall per seat | Mean | Locality |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Main today | 6, 6, 7, 6 | 6.25 | 5, 5, 6, 6 |
|
||||||
|
| A: internals only; a `Link` object per socket | 6, 6, 6, 6 | 6.0 | 5, 5, 6, 6 |
|
||||||
|
| B: internals only; the lifecycle as one state machine (reducer returns effects, `Session` runs them) | 6, 6, 6, 6 | 6.0 | 6, 6, 5, 6 |
|
||||||
|
| C: contract change; `onSms` handler option, every message answered `ESME_ROK` on arrival, `sendResp()` removed, `src/` grouped into session/, messages/, wire/, defs/ | 6, 6, 6, 6 | 6.0 | 6, 6, 6, 6 |
|
||||||
|
| D: contract change; `onSms` handler, message held while its promise runs, `sendResp()` kept, no handler means refuse with the retry status | 5, 6, 7, 6 | 6.0 | 5, 6, 6, 6 |
|
||||||
|
|
||||||
|
Their full diffs are `drafts/draft-a.patch` to `drafts/draft-d.patch`; each carries the draft's own DESIGN.md.
|
||||||
|
|
||||||
|
What the panels taught:
|
||||||
|
1. **Restructuring internals under the old contract does not move the scores (A, B).** The held-message timing contract capped every seat: six exits, a `setImmediate` turn, listener counts, `captureRejections` routed through a `WeakMap`.
|
||||||
|
2. **Changing the receiving contract to an `onSms` handler removed that cap (C, D).** In C no reader named the held-message flow; D's junior still did, because "answered" lived in three places (a closure flag, a store field, and `answeredOnArrival`).
|
||||||
|
3. **The new ceiling is the session lifecycle.** Seven of eight round-two seats named the same unit they would least want to modify:
|
||||||
|
- `LinkLife`: a 4-value phase plus a separate `stopped` flag, whose initial `'up'` is an exception to its own rule.
|
||||||
|
- Seven predicates over it (`isUp`, `isAttached`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`), read by `Session`, `OutgoingRequests` and `IncomingRequests`.
|
||||||
|
- `Session.linkLost`/`end`/`dropSocket`/`comeBackUp`, whose correctness hangs on call order.
|
||||||
|
- Listeners of `disconnected`/`close` re-entering `close()` synchronously.
|
||||||
|
- `ReconnectLoop`'s own stopped flag duplicating `LinkLife`'s.
|
||||||
|
- client.ts's `bindOn` relying on `close()` reaching `stop()` before its first await, stated in another file.
|
||||||
|
|
||||||
|
B tried a single state machine, but under the old contract, where the held-message cap hid any gain.
|
||||||
|
4. **Also still cited:**
|
||||||
|
- `IncomingRequests`/`HeldMessages` call back into `Session` (`emit`, `sendReturn`, `close`, `listenerCount`).
|
||||||
|
- `OutgoingRequests`' several entry points, or lanes, and its retry loop, which depends on link state at each await.
|
||||||
|
- `ExpiringGroups` leaves enforcement to its three owners.
|
||||||
|
- The GSM 03.38 codec is still named `ascii` somewhere.
|
||||||
|
- `DlrMerger.close` really means "spend".
|
||||||
|
- There is no glossary for the SMPP terms (ESME, SMSC/MC, esm_class, data_coding, UDH, sar_*, TLV).
|
||||||
|
- SMPP section citations with no summary.
|
||||||
|
- Defaults are spread over several files.
|
||||||
|
5. **Goal checks the drafts raised:**
|
||||||
|
- C answers `ESME_ROK` before the application has taken the message, so a crash loses it. That is a goal 2 risk: "work the peer has no reason to send again is not dropped".
|
||||||
|
- D's "no handler, so refuse every inbound message with the retry status" is a judgement call. If you keep something like it, record it in docs/decisions.md with the goal it rests on.
|
||||||
|
|
||||||
|
## Round three
|
||||||
|
|
||||||
|
| | Overall per seat | Mean | Locality |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| E: an `onSms` handler whose message is answered when the handler returns, plus the lifecycle as one state machine (`connected, bound, closing, down, ended`) | 5, 7, 6, 6 | 6.0 | 5, 6, 6, 6 |
|
||||||
|
| F: a Session is one socket's life, bound once and ended once; reconnect is an `SmppClient` composed above it; `onSms` answered on return | 6, 6, 7, 6 | 6.25 | 5, 5, 6, 6 |
|
||||||
|
|
||||||
|
Diffs: `drafts/draft-e.patch` and `drafts/draft-f.patch`.
|
||||||
|
|
||||||
|
What round three taught:
|
||||||
|
- **The hardest unit moves every round.**
|
||||||
|
- Round 1: the held-message flow.
|
||||||
|
- Round 2: the handler contract removed it, and the lifecycle took its place.
|
||||||
|
- Round 3: F's one-socket session removed the lifecycle, and readers now name other units:
|
||||||
|
- `ExpiringGroups` with `Reassembler.trim`, named by 4 of 8 seats: `set()` or `weigh()` may evict the caller's own entry, reads mutate and fire callbacks, and three owners depend on drop order;
|
||||||
|
- the invariant "every inbound PDU gets exactly one answer", enforced jointly by `IncomingRequests` and `sms.ts`.
|
||||||
|
- E's state machine was still hard, because each transition's meaning is split across five callbacks in another file.
|
||||||
|
- **Juniors score Self-sufficiency 5** even with a README glossary. The cost is SMPP knowledge: spec section numbers with no summary, and the `data_coding` bit masks. That caps a junior's overall at 6.
|
||||||
|
- **A fix sometimes adds a smaller hard spot of its own**, as D's "answered" in three places and E's callbacks show.
|
||||||
|
- **The scale is coarse**: four integer seats, so one seat moving one point shifts the mean by 0.25.
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
# Round 1: drafts A and B
|
||||||
|
|
||||||
|
## Draft A, junior seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked**
|
||||||
|
|
||||||
|
1. `src/outgoing-requests.ts:75-113` (`OutgoingRequests.request` / `requestDuringDrain` / `carrier`) together with `src/session.ts:336-371` (`linkLost`, `end`, `nextLinkExpected`). Whether a send is refused, waits or retries depends on `life`, `link.canCarry()` and `reconnectLoop`. Those live in `Session` and reach here only through the three `LinkView` closures, so reading one file means holding the other's state in my head. Line 82, `closing() && current().canCarry()`, beat me until I traced `carrier()` → `nextExpected()` → `life === 'open'`. Its comment ("refused as closed further on") points at the answer without giving it. `linkLost` reads `nextLinkExpected()` before `close()`, and only the comment explains why. That comment helped; the rest stayed half-opaque.
|
||||||
|
2. `src/held-messages.ts:40-170` (`HeldMessage`, `HeldMessages.offer`), with `src/sms.ts:77-162` and `src/session.ts:104`. A message has six exits across three files: a `working` listener counter, `answered()` deferring by `setImmediate`, a `WeakMap` from `Sms` to hold, and `emit()` returning false meaning release. `captureRejectionSymbol` calls `this.link.held.rejected(...)`, which is always the current link, so the message is only found if the link has not been replaced since the emit. The numbered exit list in the doc comment is the only reason I followed this.
|
||||||
|
3. `src/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`). The `CodingSource` idea is hard: `data_coding` is rewritten from whichever body "owns" it, an empty buffer counts as `message_payload`, and a string gets encoded while a Buffer does not. That is four branches at once. The read side has a hidden coupling too: at `pdu.ts:249` `readParams` passes the already-read `sm_length` to every wire type's `read`. Only the comment at `defs/commands.ts:19-23` hints at it. Partly resolved.
|
||||||
|
4. `src/defs/encodings.ts:147-190` (`messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`). Bit masks over GSM 03.38 coding groups, and I have no domain background for them. `ASCII` means GSM 03.38 7-bit, a name that lies; only the README encoding table fixed that for me. The `//` comment at 159-160 sits above the JSDoc of the function it describes, so it reads as floating. Stayed opaque at bit level.
|
||||||
|
5. `src/dlr.ts:157-239` (`messageType`, `receiptStatus`, `dlrFromPdu`). There are four message types, TLV-over-body precedence for both id and state, and an `unmarked` case that needs both an id and a status to count as a receipt. Comments cite spec sections I can't check. It reads correctly, but only after two passes.
|
||||||
|
6. `src/reassembly.ts:187-207` (`Reassembler.trim`) with `src/expiring-groups.ts:18,70-88`. `weigh()` may evict the very group being added, and `answered = parts.size - 1` subtracts the refused segment. The contract "enforces neither max nor timeout itself; only weigh() evicts" splits enforcement between owner and store. Resolved by the comments, but costly.
|
||||||
|
7. `src/dlr-merger.ts:150-172` (`open`, `close`). `close()` does not close a group: it moves the base into a second `ExpiringGroups` called `spent`. The misleading name cost me a reread. The class doc resolved it.
|
||||||
|
8. `src/drain.ts:20-56` (`drain`, `leftOf`, `messagesBudget`). The doc says "one budget", but messages get their own budget with a fallback while requests get what is left. 0 means "forever", so `leftOf` clamps to 1. Small but inverted.
|
||||||
|
|
||||||
|
2. **Least want to modify:** `HeldMessages` / `HeldMessage` (`src/held-messages.ts`). Its lifetime is decided by timing (`setImmediate` so that `sendDlr` still goes out past a drain), by listener counts, by identity checks against reused sequence numbers, and by callers in `session.ts` and `sms.ts`. A change to any exit risks a drain that hangs or one that ends early, and nothing local would show it.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:** `PduFramer`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, and the TLV table's type-level keying (`defs/tlvs.ts`). `defs/types.ts` is 684 lines but repetitive and uniform. `client.ts` and `server.ts` are shallow and read top-down.
|
||||||
|
|
||||||
|
4. **Prose debt:**
|
||||||
|
- Needed:
|
||||||
|
- The README encoding table, to learn that `ASCII` means GSM 7-bit.
|
||||||
|
- The AGENTS "GSM 7-bit is sent unpacked" section, to see why `segmentUnits` holds 153 against 134.
|
||||||
|
- The README "Server in depth" and "Shutdown" sections, to understand `answeredOnArrival` and what the drain waits for.
|
||||||
|
|
||||||
|
Finding them meant scanning a 773-line README; AGENTS has no anchors from code to sections.
|
||||||
|
- The decisions index in AGENTS gives titles only. The reasoning is in `docs/decisions.md`, which I was not allowed to read, so rules like "a drain ignores `shutdownTimeout: 0`" had to be recovered from code comments.
|
||||||
|
- Told me nothing the code did not:
|
||||||
|
- The 0.4.0 defect table, which says nothing about the current code.
|
||||||
|
- Most of the AGENTS architecture list, which restates filenames.
|
||||||
|
- Most decision-index lines, which repeat what an adjacent code comment already says.
|
||||||
|
- One-liners such as "Sends a request and resolves with the peer's response" on `send()`.
|
||||||
|
|
||||||
|
I opened no tests.
|
||||||
|
|
||||||
|
5. **Scores.** The problem is intrinsically hard (a protocol I don't know, plus async link lifecycles); that gets no bonus below.
|
||||||
|
- **Navigation 7 (Predictable):** file names match behaviours one-to-one, but "what happens to a send during shutdown" lives across `session.ts`, `outgoing-requests.ts`, `link.ts` and `drain.ts`, with no single entry point.
|
||||||
|
- **Locality 5 (Honest middle):** `Session` injects its state as closures (`LinkView`, `Link.on`) and passes itself back into `HeldMessages` and `IncomingRequests`, and correctness hangs on ordering that is only named in comments (`linkLost` before `close`, `setImmediate` in `answered`).
|
||||||
|
- **Shape 6 (between 5 and 7):** fan-out stays bounded per level, but several names lie: `ASCII` for GSM, `DlrMerger.close` for "mark spent", `string` for a length-prefixed Octet String next to `cstring`, and `answered()` meaning "release a turn later".
|
||||||
|
- **Self-sufficiency 6 (between 5 and 7):** dense, spec-citing comments carry most units, but the domain vocabulary (GSM alphabet naming, segment budgets, what `answeredOnArrival` means) needs the README open beside the code.
|
||||||
|
- **Overall 6:** capped at locality plus one by the cross-file lifecycle state.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=5 shape=6 self=6 overall=6
|
||||||
|
|
||||||
|
## Draft A, mid seat
|
||||||
|
|
||||||
|
1. **Hardest places, hardest first**
|
||||||
|
|
||||||
|
1. `src/held-messages.ts:252-435`, `HeldMessage` / `HeldMessages` (and `session.ts:95-107`, the `captureRejectionSymbol` override). There are six exits, each in a different method. I had to trace this chain across files: a listener rejects, Node's `captureRejections` calls the session, the session calls `this.link.held.rejected(rest[0])`, a WeakMap is searched by object identity, `listenerGaveUp()` counts down from a `listenerCount('sms')` taken when the message was offered, `answered()` waits a turn in `setImmediate`, `release()` compares the array by identity, `settle()` runs, and finally `IdleWaiters` wakes the drain in `drain.ts`. The comment listing the six exits made it readable. What stayed unclear: why the rejection goes to the *current* link's store, which may not be the link the message arrived on after a reconnect. I also had to work out that `send()` choosing `sendPastDrain` exists only because `OutgoingRequests.request` refuses sends while closing.
|
||||||
|
2. `src/outgoing-requests.ts:510-613`, `request` / `requestDuringDrain` / `bindOnCurrentLink` / `requestOnCurrentLink` / `carrier`. That is four ways onto the wire, and each skips a different check. Line 517 (`closing() && current().canCarry()`) refuses only when a link is up. The comment "refused as closed further on" meant tracing `carrier()` to see that `nextExpected()` is false while closing. `responseTimeout` is reused as the deadline for waiting on a link, `deadline === 0` means forever, and the retry loop depends on `retryOnNextLink` from `Link.send`. The `LinkView` closures read `Session` private state from a distance. The comments resolved most of it after two reads.
|
||||||
|
3. `src/session.ts:208-366`, `unbind` / `drain` / `comeBackUp` / `linkLost` / `end`. The ordering does the work. `unbind` drains, then goes around the closing refusal via `requestOnCurrentLink`. `closedOnUnbind` decides which error wins. `comeBackUp` sets `this.link` before the bind succeeds, and `linkLost` must read `nextLinkExpected()` before `close()` because a listener may re-enter. The inline comments ("Read before close()…", "close() can land while…") resolved it, but I had to hold five states at once.
|
||||||
|
4. `src/reassembly.ts:187-207`, `Reassembler.trim`, with `src/expiring-groups.ts:245-315`, `ExpiringGroups.weigh`. `ExpiringGroups` applies its three limits differently: the owner checks `full`, the owner calls `takeExpired`, and only `weigh` evicts. Map insertion order stands in for age, and `set()` re-inserts, so a replaced entry becomes the newest. `trim` can evict its own group, and it then counts `size - 1` as lost because the refused segment "stays with the peer". The comments state each rule, but checking the arithmetic took three rereads.
|
||||||
|
5. `src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`, plus `pdu.ts:249`. `CodingSource` decides whether `short_message` or `message_payload` is allowed to set `data_coding`, and an empty buffer flips the answer. `readParams` passes `sm_length` as the length argument to *every* field read, and only `buffer.read` uses it. That is a hidden coupling that neither line mentions. With no SMPP background this stayed half-opaque.
|
||||||
|
6. `src/defs/encodings.ts:1,105-190`, `EncodingName` and `messageClassEncoding` / `encodingByDataCoding`. The name `'ASCII'` means GSM 03.38, and nothing says so until `dataCodingByEncoding`'s comment. It also misleads in `message.ts:343` and `message.ts:402` (`resolved === 'ASCII'` means septet packing). The bit masks (`0x80`, `0xF0`, bits 3-2) were an algorithm I had no context for. Two comments sit stacked in reverse order at lines 159-161. The charter's "GSM 7-bit is sent unpacked" section explained the 153.
|
||||||
|
7. `src/dlr.ts:357-439`, `messageType` / `receiptStatus` / `dlrFromPdu`. There are four message types, and `'unmarked'` becomes a receipt only if both an id and a state can be scraped. Otherwise `IncomingRequests.onDelivery` (`incoming-requests.ts:152`) quietly reroutes it to `onMessage`. The rule is local and commented, but it only makes sense with the spec's `esm_class` bits in mind.
|
||||||
|
8. `src/client.ts:258-330`, `keepTrying` / `initialAttempts`. There is a second `ReconnectLoop` outside the session, a fresh `Session` per attempt, and a `lastErr` captured in closures. The code itself is clear; the cost was noticing that there are two loops.
|
||||||
|
|
||||||
|
2. **Unit I would least want to modify:** `HeldMessages` / `HeldMessage` (`src/held-messages.ts`). Its correctness depends on timing (`setImmediate`), a listener count taken at `emit` time, identity lookups (the WeakMap, and array identity in the store), and callers in `session.ts`, `incoming-requests.ts`, `sms.ts` and `drain.ts` that each use a different exit. A change there fails as a hung or cut-short shutdown, which is hard to see in a test.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:** the wire codec. `defs/types.ts` is long but uniform. `PduFramer`, `parseTlvs` / `writeTlvs`, `readOptionalParams` (its NULL-pad rule is commented), `ReconnectLoop`, `bind-direction.ts`, `sms-id.ts` and the `Result<T>` convention were all quick. `udh.ts`'s `concatInfo` explains its walk over the header elements well enough for a newcomer to the domain.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **Needed:**
|
||||||
|
- The AGENTS.md architecture map was cheap and correct; it is how I found every file. The file list matches `src/`.
|
||||||
|
- The "GSM 7-bit is sent unpacked" section was necessary for `segmentUnits`.
|
||||||
|
- The README's Receive-SMS text was necessary to see why `sendResp()` on a multipart message writes nothing.
|
||||||
|
- Missing everywhere: a one-line glossary of ESME/SMSC, `esm_class`, `data_coding`, UDH and `sar_*`. The only one is the `LinkEnd` comment for ESME/SMSC. Comments cite spec sections ("SMPP 3.4 5.2.12") that I cannot open, so for someone new to the domain they are pointers, not definitions.
|
||||||
|
- The decisions index names rules ("the drain's wait on the application ignores `shutdownTimeout: 0`") that I then found stated in the code (`drain.ts` `messagesBudget`). The index cost scrolling and gave nothing the code did not.
|
||||||
|
- I opened no tests.
|
||||||
|
- **Told me nothing new:**
|
||||||
|
- The AGENTS "Defects found in 0.4.0" table: history, not needed to read this code.
|
||||||
|
- Most of the Conventions paragraph on test fixtures, for reading `src/`.
|
||||||
|
- Doc comments that restate the code: `Link.canCarry` ("Whether a request can go out on it right now"), `HeldMessages.isGone`, `Session.bindAllows` ("Consulted by the library's senders"), `IdleWaiters.settle`, `PduRefusedError`'s class comment.
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
- **Navigation 7:** at the "predictable" anchor. The one-line-per-file map and concept-named files (`link.ts`, `drain.ts`, `reassembly.ts`) got me from symptom to file first try. It stops short of 9 because shutdown behaviour lives in five files (`session.ts`, `drain.ts`, `held-messages.ts`, `outgoing-requests.ts`, `idle-waiters.ts`).
|
||||||
|
- **Locality 5:** at the "honest middle" anchor. Most modules stand alone, but the held-message/drain path runs on hidden timing (`setImmediate`), a listener count taken early, rejection routing to whatever link is current, and `LinkView` closures reading `Session` private state. The order-dependent sequences in `Session.linkLost` and `unbind` add to it.
|
||||||
|
- **Shape 6:** between the middle and predictable anchors. Fan-out is bounded (`Session` → `Link` → `PendingRequests` / `HeldMessages` / `Reassembler`), but some names lie or clash:
|
||||||
|
- `'ASCII'` means GSM 03.38.
|
||||||
|
- In `sms.ts`, `answered` is both a mutable `{ smsId }` holder (line 81) and a handler function (line 69).
|
||||||
|
- `HeldMessage.held.held` chains through two different things both called `held`.
|
||||||
|
- `lostLink()` is a predicate named like an event.
|
||||||
|
- There are four request entry points on `OutgoingRequests`.
|
||||||
|
- **Self-sufficiency 6:** between the middle and predictable anchors. Comments are dense and carry the why at the call site (the six-exit list, "Read before close()"). But domain terms are never glossed and the spec section numbers point outside the repo, so the `data_coding` and `esm_class` bit logic in `encodings.ts` and `dlr.ts` cannot stand alone for a reader new to SMPP.
|
||||||
|
- **Overall 6:** capped at locality + 1. The hard parts are few and mostly marked, but the one I would fear most (held messages and the drain) spreads across files and relies on timing.
|
||||||
|
- **Intrinsic difficulty** (no bonus): moderate-high. The protocol has two segmentation spellings, receipts that share a command with messages, a direction-dependent `data_sm`, and graceful drain combined with reconnect.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=5 shape=6 self=6 overall=6
|
||||||
|
|
||||||
|
## Draft A, senior seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked**
|
||||||
|
|
||||||
|
1. **`src/held-messages.ts:40` `HeldMessage`, and `HeldMessages.offer` at `:148`.** Following this one flow meant holding five files at once:
|
||||||
|
- `sms.ts:127` `sendResp` and `:210` `sendDlr`.
|
||||||
|
- The `setImmediate` in `answered()` at `:58`.
|
||||||
|
- `HeldMessage.send` at `:77`, which picks between `sendPastDrain` and `session.send` by asking `isHeld()`.
|
||||||
|
- `OutgoingRequests.requestDuringDrain`.
|
||||||
|
- `Session`'s `captureRejectionSymbol` at `session.ts:104`, which gets back to the hold through a `WeakMap` keyed on the `Sms`.
|
||||||
|
|
||||||
|
A `sendDlr()` gets past a drain only while the release has not yet happened, and that is one event-loop turn. `working` is `listenerCount('sms')` taken at offer time, and the guarded `emit` returning false feeds exit 3. The six-exits comment and the README's Shutdown section settled it, but only after I had read both.
|
||||||
|
|
||||||
|
2. **`src/outgoing-requests.ts:75` `request`, with `:90` `requestDuringDrain`, `:115` `bindOnCurrentLink`, `:133` `requestOnCurrentLink` and `:160` `carrier`.** There are four ways onto a link, and each skips a different mix of four things: the drain refusal, the send window, the wait for a link and the retry.
|
||||||
|
- Line 94, `closing() && current().canCarry()`, only makes sense with the comment "refused as closed further on".
|
||||||
|
- `misuse()` is checked twice.
|
||||||
|
- Whether the bind and the unbind count toward the drain's `window.idle()` has to be worked out from the fact that they skip `attemptOn`.
|
||||||
|
|
||||||
|
I followed it in the end, but did not come away sure of the edge cases.
|
||||||
|
|
||||||
|
3. **`src/session.ts:234` `answer`, with `incoming-requests.ts:85` and `sms.ts:127`.** `sendReturn` always writes to `this.link`, the current link. The rule that "an answer belongs to the link the message arrived on" is held by callers checking `link.isClosed()` or `lostLink()` before they call, in two separate places. `sendReturn` never enforces it. I had to hunt for this, and only the decision titles in AGENTS.md told me the rule exists.
|
||||||
|
|
||||||
|
4. **`src/pdu.ts:84` `resolveShortMessage` / `:113` `resolveBody`.** `CodingSource` decides whether `short_message` or `message_payload` sets `data_coding`. I had to hold these cases at once:
|
||||||
|
- Buffer or string or absent.
|
||||||
|
- Empty or non-empty.
|
||||||
|
- `data_coding` given or not.
|
||||||
|
- An empty `short_message` that still makes `message_payload` the source.
|
||||||
|
|
||||||
|
The type comment at `:74` helps. It still took two reads.
|
||||||
|
|
||||||
|
5. **`src/reassembly.ts:111` `collect` / `:188` `trim`, on top of `expiring-groups.ts:18`.** `ExpiringGroups` enforces its limits unevenly:
|
||||||
|
- `set()` never evicts and `weigh()` does.
|
||||||
|
- `full` is only advisory.
|
||||||
|
- `onSweep` must itself call `takeExpired()`.
|
||||||
|
|
||||||
|
`trim` counts `parts.size - 1` because the segment was added before weighing and may be the one evicted. The comments state each quirk, but I needed all of them at the same time.
|
||||||
|
|
||||||
|
6. **`src/session.ts:208` `unbind` and `:336` `linkLost`.** In `unbind`, the three booleans `wasOpen`, `closedOnUnbind` and `drained` decide which error wins. In `linkLost`, "read `nextLinkExpected` before `close()`" depends on `link.close()` calling `reassembler.clear()`, which emits `sessionError` synchronously to a listener that might call `close()`. That is state changed out of sight. The comment names the risk but not the path it takes.
|
||||||
|
|
||||||
|
7. **`src/dlr-merger.ts:150` `open` / `:165` `close`.** Here `close` means "mark as spent", not "tear down", and `spent` is a second `ExpiringGroups<true>` with its own cap and eviction. The class comment explains the purpose, but the method name misleads.
|
||||||
|
|
||||||
|
8. **`src/client.ts:286` `keepTrying` / `:263` `initialAttempts`.** There are two different `ReconnectLoop` owners: the session's loop, and a separate one that runs only for the first connect. There is also a `lastErr` closure and a comment about `unref: false`. It was readable once I saw that `fromStart` builds a fresh `Session` for every attempt.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify:** `OutgoingRequests` together with its callers `HeldMessage.send` and `Session.unbind`. Whether a send is refused, queued or bypassed depends on which of the four entry points was chosen, and those choices are made in three other files. A change to one bypass has no local test of whether the drain still counts it.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:**
|
||||||
|
- The codec: `defs/types.ts` and the TLV read/write. It is mechanical and every read is range-checked.
|
||||||
|
- UDH walking in `udh.ts`.
|
||||||
|
- GSM encoding and `splitMessage`.
|
||||||
|
- Parsing receipt dates.
|
||||||
|
- `ReconnectLoop`'s backoff reset.
|
||||||
|
|
||||||
|
These are local and commented at the right spots, with SMPP section references.
|
||||||
|
|
||||||
|
4. **Prose debt.**
|
||||||
|
- **Needed:** the AGENTS.md architecture map (accurate, and my main way to navigate). The README "Session / Shutdown" and "Sends and the link" bullets, for the drain and held-message rules. The AGENTS decision-index titles, for why answers are tied to a link and why a close after our own `unbind` is clean. Cost was moderate: all of it in two files I had already read, but the "one turn later" rule for `sendDlr` is stated in full only in README step 1.
|
||||||
|
|
||||||
|
A comment that is wrong: `SessionOptions.shutdownTimeout` says it bounds only "the requests already on the wire", but `drain.ts:25` also uses it for held messages.
|
||||||
|
|
||||||
|
Defaults are scattered with no pointer between them:
|
||||||
|
- A separate `defaults` object in each of `client.ts`, `server.ts` and `session-options.ts`.
|
||||||
|
- `backoffDefaults` in `reconnect-loop.ts`.
|
||||||
|
- `defaultMaxOctets` in `reassembly.ts`, which duplicates `maxHeldOctets`.
|
||||||
|
|
||||||
|
Finding where the default for a given option lives took a search.
|
||||||
|
- **Told me nothing about the current code:**
|
||||||
|
- The AGENTS 0.4.0 defects table: history, and it never helped me read `src/`.
|
||||||
|
- Most of AGENTS "Conventions", which is about test fixtures and teardown.
|
||||||
|
- One-liners that restate the code, such as `/** Sends a request and resolves with the peer's response. */` and `/** Answers a request the peer sent us. */`, plus the `ConcatInfo`/`udhLength` comments.
|
||||||
|
|
||||||
|
I did not open any test.
|
||||||
|
|
||||||
|
5. **Scores.** The problem is intrinsically hard: an SMPP session layer with reconnect, drain, windowing, reassembly and receipt merging. It gets no bonus here.
|
||||||
|
- **Navigation 7** (anchor 7): the AGENTS file map answers "where does this live" accurately for every file. It is held below 8 because a symptom like "`sendDlr` refused during shutdown" lands across four files, and "which default" across three objects all called `defaults`.
|
||||||
|
- **Locality 6** (between 5 and 7): the codec, defs, dlr and message modules are fully local. The session layer is not: `Session` passes itself into `IncomingRequests`, `HeldMessages` and `createSms`, the link-answer rule is enforced by callers, and the drain bypass depends on a `setImmediate` in another file.
|
||||||
|
- **Shape 6** (between 5 and 7): fan-out is bounded and most names are true. Some are not:
|
||||||
|
- `ASCII` means GSM 03.38.
|
||||||
|
- `HeldMessages.full()` sweeps, logs and flips state.
|
||||||
|
- `DlrMerger.close` means "mark as spent".
|
||||||
|
- `Session.link` is documented as "the latest socket".
|
||||||
|
|
||||||
|
Four near-identical send entry points on `OutgoingRequests` also cost this score.
|
||||||
|
- **Self-sufficiency 7** (anchor 7): the why-comments sit where they are needed (the six exits, the read-before-close note, the SMPP section numbers). Only the drain and held-message semantics needed the README open beside the code.
|
||||||
|
- **Overall 6:** capped at 7 by locality, and held at 6 because the part that is hardest to change safely, the session layer, is also where the cross-file coupling sits.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft A, architect seat
|
||||||
|
|
||||||
|
**Comprehension panel report: Architect, inherited.** Target: `/tmp/claude-1000/-home-lilleman-code-smpp-js/fe9b6791-4543-5342-9fc7-efa1e22d8fc7/scratchpad/draft-a`
|
||||||
|
|
||||||
|
I read README.md, AGENTS.md in draft-a, and every non-test file under src/. I read `defs/` down to its exports and skimmed the rest of it for shape. I opened no test.
|
||||||
|
|
||||||
|
## 1. Map from README and the file tree only (verbatim)
|
||||||
|
|
||||||
|
```
|
||||||
|
Top-level areas I expect (7):
|
||||||
|
A. Entry/wiring — index.ts (public surface), client.ts (connect+bind+reconnect policy), server.ts (listener, auth, sessions set).
|
||||||
|
B. Session life — session.ts (the EventEmitter, lifecycle, close/unbind), session-options.ts (options + defaults + validation),
|
||||||
|
bind-direction.ts (bind types, which end, what a bind allows), reconnect-loop.ts (backoff), link.ts (?? one socket? timers?),
|
||||||
|
drain.ts (graceful-shutdown wait).
|
||||||
|
C. Requests out — outgoing-requests.ts (send path), pending-requests.ts (seqNr correlation + timeout),
|
||||||
|
send-window.ts (maxOutstanding), unanswered-error.ts (the "may have been taken" error), idle-waiters.ts (?? something waits for zero).
|
||||||
|
D. Requests in / messages — incoming-requests.ts (dispatch of what the peer sends), sms.ts (the 'sms' handle, sendResp/sendDlr),
|
||||||
|
held-messages.ts (the 1000-unanswered bound from README "Unanswered messages"), reassembly.ts + expiring-groups.ts
|
||||||
|
(multipart store; expiring-groups probably shared), concat.ts + udh.ts (segment detection UDH vs sar_*),
|
||||||
|
message-body.ts (short_message vs message_payload), message.ts (encode/split), send-sms.ts (sendSms composition),
|
||||||
|
retained-pdu.ts (?? memory accounting per maxOctets).
|
||||||
|
E. Receipts — dlr.ts (parse), dlr-merger.ts (messageDlr), sms-id.ts (smsIdFormat notations, <base>-<n>).
|
||||||
|
F. Codec — pdu.ts, pdu-framer.ts, pdu-refusal.ts (PduRefusedError), defs/* (spec tables, wire types, encodings).
|
||||||
|
G. Plumbing — result.ts, log.ts, error-from.ts (?? error from unknown), uuid.ts.
|
||||||
|
|
||||||
|
Unclear by name: link.ts, idle-waiters.ts, retained-pdu.ts, error-from.ts; overlap suspected between drain.ts / idle-waiters.ts / held-messages.ts.
|
||||||
|
Expected but not visible: no keepalive/timer file (README's enquire_link + idleTimeout) — guess it's in link.ts or session.ts;
|
||||||
|
no socket/transport file; no store (goal 9) — README says it has not shipped, so absence is honest; interop-tests/ and benchmarks/ are outside src/test.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where the map was wrong, and what each correction cost:**
|
||||||
|
- **link.ts (medium).** I expected a socket plus its timers. `Link` also owns the `PendingRequests`, the `Reassembler` and the `HeldMessages`. So held messages and reassembly belong to one socket, not to the session. That changes how you reason about the drain after a reconnect, and I had to rebuild part of the model.
|
||||||
|
- **held-messages.ts (medium).** I expected a counter. It holds six exit paths, a WeakMap for listener rejections and a back-reference to `Session`. It also sends through a bypass path (`sendPastDrain`).
|
||||||
|
- **sms.ts (low to medium).** I expected only the inbound handle. It also builds outbound delivery receipts (`receiptText`, `receiptTlvs`, `collectReceipt`), while `dlr.ts` parses them. Writing and reading receipts are split across two files.
|
||||||
|
- **Smaller surprises (low).** `reassembly.ts` exports `decodeSegments`, which `sms.ts` uses to build `sms.message`. `message.ts` also holds `smppTime` and `smppDate`.
|
||||||
|
- **drain, idle-waiters, retained-pdu, error-from (cheap).** Each turned out as guessed. `drain.ts` is budget arithmetic only.
|
||||||
|
- **Keepalive (right).** The timers are in `link.ts` (`resetTimers`).
|
||||||
|
|
||||||
|
## 2. Fan-out level by level
|
||||||
|
- **L0, repo:** src/, test/, README, AGENTS. Trivial.
|
||||||
|
- **L1, src/: 35 files plus defs/, all flat. This is the worst level.** My map needed 7 areas, but the layout shows none of them, and the AGENTS.md architecture list is not grouped by area either. I had to hold about 36 names to sort them.
|
||||||
|
- **L2, defs/:** 7 files, all spec tables. Bounded.
|
||||||
|
- **L3, units:**
|
||||||
|
- `session.ts`: about 20 members, but grouped, with a stated invariant ("every event about the life is emitted from one of these four").
|
||||||
|
- `link.ts`: about 12 members.
|
||||||
|
- `outgoing-requests.ts`: 4 public ways in (`request`, `requestDuringDrain`, `requestOnCurrentLink`, `bindOnCurrentLink`), the one level where the fan-out is too wide for the concept.
|
||||||
|
- `defs/types.ts`: 684 lines, but a table of wire types, so it is wide without being hard.
|
||||||
|
|
||||||
|
## 3. Names
|
||||||
|
**Misleading:**
|
||||||
|
- **`idle`** means two things. `HeldMessages.idle()`, `SendWindow.idle()`, `OutgoingRequests.idle()` and `IdleWaiters` mean "wait until the count reaches zero". `idleTimeout` and "closing an idle peer" in link.ts mean the peer has gone silent.
|
||||||
|
- **`ExpiringGroups`** enforces neither its `max` nor its timeout (its own comment says owners must). `DlrMerger.spent` is an `ExpiringGroups<true>`, a set dressed as groups.
|
||||||
|
- **`EncodingName 'ASCII'`** means GSM 03.38. It is public, legacy and documented, but it is still a false name.
|
||||||
|
- **`lostLink()`** on `SmsHandlers` is a predicate named like an event.
|
||||||
|
- **`message.ts`** also holds `smppTime` and `smppDate`.
|
||||||
|
|
||||||
|
**One concept with two or more names:**
|
||||||
|
- **Answering a request has four spellings:** `sendReturn`, `pduReturn`, `Session.answer()` and `sendResp`.
|
||||||
|
- **Letting a send past the drain has two:** `sendPastDrain` and `requestDuringDrain`.
|
||||||
|
- **The socket has three:** link, `sock` and socket.
|
||||||
|
- **A delivery report has three:** receipt, dlr and report.
|
||||||
|
- **A segment has two:** part and segment.
|
||||||
|
|
||||||
|
**One name over two concepts:**
|
||||||
|
- **"held"** covers messages the application has not answered (`Link.held`), requests waiting for a link ("holding a request until a link is back", "sending what was held for a link"), and `HeldMessages.held`, the inner ExpiringGroups.
|
||||||
|
- **`drain`** is both `Session.drain()` (private) and `drain()` in drain.ts.
|
||||||
|
- **`defaults`** is three different objects: session-options.ts, client.ts and server.ts, with `systemId` in two of them.
|
||||||
|
- **`Waiter`/`waiting`** is defined separately in outgoing-requests.ts and send-window.ts with different meanings.
|
||||||
|
- **"refuse/refusal"** covers codec refusal (`PduRefusedError`), segment refusal (`Refusal 'full'|'unplaceable'`) and option refusal in send-sms.
|
||||||
|
|
||||||
|
## 4. What I would restructure, ranked
|
||||||
|
1. **Group src/ into about 5 directories:** codec/, link+session/, inbound messages/, outbound send/, receipts/, plus defs/. This is the only thing pushing L1 past its bound. AGENTS.md records "src/ stays flat" as a decision, and I would contest it: at 36 files, the map lives in AGENTS.md, not in the layout.
|
||||||
|
2. **Settle on one verb for answering a request.**
|
||||||
|
3. **Move receipt composition out of sms.ts** next to the parsing in dlr.ts. Merge `collectReceipt` (`sms.ts:188`) with `collectSent` (`send-sms.ts:274`); they are near-duplicates.
|
||||||
|
4. **Make `ExpiringGroups` enforce its own `max` and weight, or rename it to say the caps are advisory.** Today three owners each implement eviction differently: `Reassembler.open` plus `trim`, `DlrMerger.dropOldest` plus `spent`, and `HeldMessages.full()` with its own weight comparison.
|
||||||
|
5. **Rename the "wait until zero" methods** (`idle()` → `drained()`), and give "held" one meaning.
|
||||||
|
6. **Move `smppTime` and `smppDate` into their own module, and keep one `defaults`.**
|
||||||
|
|
||||||
|
**What the structure gets right:**
|
||||||
|
- `session.ts` is a readable orchestrator with a single exit for a link (`linkLost`) and a single end (`end`).
|
||||||
|
- `Link.close()` runs once and takes down everything tied to that socket.
|
||||||
|
- The Result discipline is uniform.
|
||||||
|
- The codec is pure and synchronous.
|
||||||
|
- Small modules with honest names: `pdu-framer`, `send-window`, `pending-requests`, `reconnect-loop`, `unanswered-error`.
|
||||||
|
- Log messages are static and prefixed with the unit (`'heldMessages - …'`, `'drain - …'`), so a log line leads straight to its unit. This is the strongest navigation aid in the code.
|
||||||
|
- Comments give the WHY, often with a spec section.
|
||||||
|
|
||||||
|
## 5. The 3am question
|
||||||
|
Symptom: during a graceful shutdown the session hangs until the shutdown timeout, even though the application called `sms.sendResp()` on every message.
|
||||||
|
|
||||||
|
**Cold time to the right unit: about 5 minutes.**
|
||||||
|
1. `Session.close` leads to `Session.drain` (`session.ts:251`).
|
||||||
|
2. That leads to `drain()` (`drain.ts:32`). Its err text and warn logs already say which half stalled: "messages unanswered" or "requests unfinished".
|
||||||
|
3. **Messages half:** `HeldMessages.idle` and `HeldMessages.release` (`held-messages.ts:185-222`), reached from `HeldMessage.answered()` (`held-messages.ts:57`).
|
||||||
|
- The gate is `sms.ts:159`, `if (!failure) handlers.answered()`. If any `sendReturn` for the message fails, the message is never released. Two ways that happens: an `smsId` the latin1 codec refuses, or a failed write. It then stays held until the drain budget runs out (and the 5-minute sweep drops it after that). The application saw `err` from `sendResp()` and ignored it.
|
||||||
|
- Release also works by object identity (`held.get(key) !== pduObjs`), so check that too.
|
||||||
|
4. **Requests half:** `sendDlr` goes past the drain through `requestDuringDrain` (`outgoing-requests.ts:90`). It holds a send-window slot until the peer answers the `deliver_sm`, so a peer that is itself shutting down leaves it until the deadline. That is the other likely cause, and nothing in the symptom rules it out.
|
||||||
|
|
||||||
|
**Where it rots first:** the triangle of `HeldMessage`, `HeldMessages`, `Sms` and `Session`.
|
||||||
|
- `Link` is handed a `session` through its options.
|
||||||
|
- `HeldMessages` emits `'sms'` on `Session`.
|
||||||
|
- A listener rejection comes back through `Session[captureRejectionSymbol]` into `this.link.held.rejected(rest[0])` (`session.ts:104`). That is the current link, not necessarily the one the message arrived on.
|
||||||
|
- Release depends on ordering: `setImmediate` in `answered()` exists so that a `sendDlr()` called straight after `sendResp()` still gets past the drain.
|
||||||
|
- The next fix here will add a seventh exit.
|
||||||
|
|
||||||
|
**Where the next two features would land:**
|
||||||
|
- **Goal 9, the store.** It lands on `ExpiringGroups`, the store its three owners share. That class is synchronous and in-memory, and each owner enforces part of its contract, so moving to an async store interface touches `DlrMerger`, `Reassembler` and `HeldMessages` at once. Expensive.
|
||||||
|
- **Goal 7, a per-PDU rate limit or a custom alphabet.**
|
||||||
|
- A rate limit lands cleanly at `OutgoingRequests.attemptOn` (`outgoing-requests.ts:124`).
|
||||||
|
- A custom alphabet runs into the closed `EncodingName` union. It spreads into `message.ts:14` (`segmentUnits`), `send-sms.ts:92` (`dataCodingFor`, with hard-coded 0x10/0x18), `defs/encodings.ts` and `bitCount`: 4 to 5 places.
|
||||||
|
|
||||||
|
## 6. Hardest places, ranked
|
||||||
|
1. `src/held-messages.ts:40-223`, `HeldMessage` and `HeldMessages`: six exits, release by identity, WeakMap for rejections, `setImmediate` release, back-reference to the session, the drain bypass.
|
||||||
|
2. `src/session.ts:95-107`, `[captureRejectionSymbol]`: a rejected `'sms'` listener reaches the held messages through an `unknown` argument on the current link. Action at a distance.
|
||||||
|
3. `src/outgoing-requests.ts:75-137`: `request`, `requestDuringDrain`, `bindOnCurrentLink` and `requestOnCurrentLink` are four ways in with different bypass rules. The condition `closing() && current().canCarry()` (line 82) reads inverted until you notice the fall-through.
|
||||||
|
4. `src/expiring-groups.ts:19-147`, `ExpiringGroups`: the contract is half-enforced, and each owner fills in the rest differently.
|
||||||
|
5. `src/dlr-merger.ts:68-184`, `DlrMerger`: `groups` plus `spent`, and `close()` always marks an id spent.
|
||||||
|
6. `src/drain.ts:20-57` with `src/session.ts:251`: budget arithmetic where 0 means forever and the messages half falls back to `responseTimeout`. `session-options.ts:63` documents `shutdownTimeout` as covering only the requests on the wire, a partial truth next to the code that owns the behaviour.
|
||||||
|
7. `src/client.ts:228-330`, `bindOn`, `initialAttempts` and `keepTrying`: a second `ReconnectLoop` outside `Session`, with a fresh session per attempt.
|
||||||
|
8. `src/pdu.ts:84-136`, `resolveShortMessage` and `resolveBody`: which body field gets to set `data_coding`.
|
||||||
|
|
||||||
|
**The unit I would least want to modify:** `HeldMessages` and `HeldMessage` in `src/held-messages.ts`.
|
||||||
|
|
||||||
|
**Intrinsic difficulty (no bonus):** high. An SMPP session layer with reconnect, a drain split into two budgets, sequence-number correlation that belongs to one link, reassembly under caps, and asynchronous lifecycle races. Most of the hard spots are hard because of this.
|
||||||
|
|
||||||
|
## 7. Scores
|
||||||
|
- **Navigation: 7.** At the "Predictable" anchor: honest file names plus unit-prefixed static log strings took the 3am symptom from `session.ts` to `drain.ts` to `held-messages.ts`/`sms.ts:159` with no detour. It stays below 9 because the flat 36-file src/ makes you consult AGENTS.md's list to find areas.
|
||||||
|
- **Locality: 6.** Between the anchors: `Link` and `Session` hold clean boundaries, but the held-message flow reaches back into `Session` through `Link` options, relies on `setImmediate` ordering, and gets rejections through `captureRejections` on the current link. And three owners each re-enforce `ExpiringGroups`' caps.
|
||||||
|
- **Shape: 6.** Between the anchors: the flat L1 of 36 files breaks the bound and has no grouping. "held", "idle", `defaults` and "drain" each name two things, and a response has four verbs. The files are small and single-purpose.
|
||||||
|
- **Self-sufficiency: 7.** At the "Predictable" anchor: invariants are stated at the code (the six-exits list, the `linkLost` comment, spec citations), and I needed no second document to follow a unit. It stays below 9 because of the partial `shutdownTimeout` doc at `session-options.ts:63` and the drain semantics, which only README fully states.
|
||||||
|
- **Overall: 6.** Held to the Locality and Shape 6s. It reads close to "Predictable": a cold senior would be productive within a week and would know to fear `held-messages.ts`.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft B, junior seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked hardest first**
|
||||||
|
|
||||||
|
1. **`src/outgoing-requests.ts:70-168`, `OutgoingRequests.request` → `requestPastDrain` → `carry` → `attempt`.** A request has four ways in. One is a recursive retry (`carry` calls itself at :130) that fires only when `retryOnNextLink && link.awaitsNextLink()`. Each hop asks `LinkLife` a different question: `isStopping`, `canCarry`, `refusal`, `budget`, `awaitsNextLink`. I had to keep LinkLife's phase table open to follow it. The guard at :77, `isStopping() && canCarry()`, has the comment "With no link, the request is refused as closed further on". That describes the branch that is *not* taken here, and I read it three times. `requestPastDrain` is named for the caller that needs it (a receipt during a drain), not for what it does. The `UnansweredError` wrap at :167 was clear. The rest stayed half-opaque.
|
||||||
|
2. **`src/link-life.ts:74-171`, `LinkLife.transition` / `lose` / `end`.** There are three pieces of state: `linkPhase`, `stopping` and `drops`. `stopping` is set in two places (:89, :167). `drops` increments both in `lose` and in `end`, and `bound` while stopping falls through to `lose()`. The effects returned are carried out elsewhere, in `session.ts:269` `run()`, so behaviour is split across two files in an order I had to trust. The type comments at :13-33 made it tractable. What really cost me was the initial phase at :60: `linkPhase = 'up'` on a socket that has not bound yet. That contradicts the `up` doc ("a bound socket carries requests") and the charter's "a bind is what makes it one". I never resolved why the first link starts `up`.
|
||||||
|
3. **`src/held-messages.ts:40-181`, `HeldMessages`.** The numbered "six ways a hold ends" comment helps, but the ways live in three files. Way 2 enters from `session.ts:97`, where `captureRejectionSymbol` passes `rest[0]` as `unknown`. It is then looked up in a `WeakMap`, and `working` is seeded from `session.listenerCount('sms')` at :161. Way 1 arrives through a callback built in `sms.ts`. Way 3 depends on `emit` returning false, both for no listener and for a throw (the override in `session.ts:78`). I pieced this together; it did not stay opaque, but it was the most action at a distance in the codebase.
|
||||||
|
4. **`src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`.** `CodingSource` decides which of two fields may set `data_coding`. An empty buffer counts as `message_payload`, and `sm_length` is filled only sometimes. With no SMPP background I could not tell why an empty `short_message` hands authority to the TLV until I read `message-body.ts` and the README's "Where the body is". After that it made sense, but it took two files and a doc.
|
||||||
|
5. **`src/client.ts:228-350`, `bindOn` / `initialAttempts` / `keepTrying` / `client`.** The `fromStart` path builds a second `ReconnectLoop` outside any session. Each attempt gets a fresh `Session`, and a `lastErr` closure is shared between two lambdas. The comment at :249, "close() must reach the loop's stop() before its first await", states an ordering invariant that lives in `session.ts` `drain()`/`apply('stopping')`. It is correct, but invisible from here.
|
||||||
|
6. **`src/defs/encodings.ts:151-198`, `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.** This is bit-masking over `data_coding` groups I had no context for. The `//` comment sits above the `/** */` block at :159-161, so it reads as belonging to the wrong function. The "ASCII" name meaning GSM 03.38 is a lie I only caught because the README's encoding table says so. It stayed partly opaque: I trust it, but I could not verify it.
|
||||||
|
7. **`src/reassembly.ts:188-207`, `Reassembler.trim`.** `ExpiringGroups.weigh()` returns evicted groups, possibly including the current one. `answered = parts.size - 1` for the current group depends on the refused segment already being in `parts`. The comments at :200 and :125 resolved it after a reread.
|
||||||
|
8. **`src/drain.ts:70-112`, `drain` / `answeringBudget`.** Two budgets with different zero-semantics, a fallback to `defaults.responseTimeout` imported from `session-options`, and a `setImmediate` turn. The comments explain each step. It was harder than it looked, but it resolved.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify:** `OutgoingRequests.carry` together with `requestPastDrain` (`src/outgoing-requests.ts:85-133`). Its correctness depends on LinkLife's phase at the exact moment of each await: whether a failed write has already produced `lost` → `dropLink`, so that `awaitsNextLink()` is true. It also depends on the send-window slot being released in `finally` before the recursion. Nothing in the unit states those timing assumptions. A change would be guessed and then tested.
|
||||||
|
|
||||||
|
3. **Expected to be hard, found easy.**
|
||||||
|
- The wire codec: `defs/types.ts` is long but completely regular, and every read/write is range-checked.
|
||||||
|
- `PduFramer`, `concatInfo`, `sms-id.ts`, `dlr.ts` receipt parsing (comments name the operators and spec sections).
|
||||||
|
- The `Result` pattern.
|
||||||
|
- `server.ts` `handleRequest`.
|
||||||
|
- `DlrMerger`, whose severity table comment says exactly why it exists.
|
||||||
|
- The session constructor, which reads as a wiring diagram.
|
||||||
|
|
||||||
|
4. **Prose debt.**
|
||||||
|
- **Needed:**
|
||||||
|
- The SMPP vocabulary: ESME vs SMSC, `deliver_sm` vs `submit_sm` direction, `esm_class`, `data_coding`, TON/NPI, UDH vs `sar_*`. Only the README supplies it, scattered across "Receiving in depth", "Bind direction" and "Delivery receipts". There is no glossary, and finding each term cost several searches.
|
||||||
|
- The charter's "GSM 7-bit is sent unpacked" section, to understand `segmentUnits` in `message.ts:14`. Cheap, because the architecture map pointed there.
|
||||||
|
- The AGENTS decision index. Its lines, for example "One owner decides whether a link can carry a request", told me a rule exists but not its reasoning. I was not allowed to open `docs/decisions.md`, and the initial-`up` puzzle is exactly where I needed it.
|
||||||
|
- **Told me nothing:**
|
||||||
|
- The 0.4.0 defect table. It is history, and useless for reading the current code.
|
||||||
|
- The long test-fixture paragraph in Conventions.
|
||||||
|
- Duplicated doc comments: `deliver` "False means nothing was" appears in both `outgoing-requests.ts` and `pending-requests.ts`, and the `idle()` "Resolves 0 once…" wording is repeated four times.
|
||||||
|
- `/** Injected so expiry can be exercised without a wall clock. */` repeated on four option types.
|
||||||
|
- `get sock` "Replaced on reconnect" in `session.ts`, which restates `PduTransport`.
|
||||||
|
|
||||||
|
5. **Scores.** The problem itself is hard (async link lifecycle, reconnect, protocol quirks); that gets no bonus below.
|
||||||
|
- **Navigation: 7.** At the "predictable" anchor: the AGENTS architecture map names every file by its question and the filenames match. It stops short of 9 because a behaviour like "a send during shutdown" spans `outgoing-requests.ts`, `link-life.ts`, `drain.ts` and `session.ts`, with no single landing file.
|
||||||
|
- **Locality: 6.** Between the middle and predictable anchors. The collaborators are cleanly split, but LinkLife's `stopping`/phase flags are read by OutgoingRequests at await boundaries, effects run in a different file from where they are decided, and HeldMessages is entered from the EventEmitter's `captureRejectionSymbol`. All of that is action at a distance.
|
||||||
|
- **Shape: 6.** Fan-out is bounded (Session has about 9 collaborators, each small), but some names lie: `ASCII` means GSM 03.38, `drain.ts` houses `IdleWaiters`, `requestPastDrain` is named for a caller, and the link starts in phase `up` before any bind.
|
||||||
|
- **Self-sufficiency: 5.** At the middle anchor. Comments are dense with spec citations and explain the WHY locally. Still, a reader without the domain needs README open for SMPP terms, and some rules exist only as index lines pointing at a decisions file.
|
||||||
|
- **Overall: 6.** Capped at 6 by self-sufficiency. The layout is good and the hard parts are really hard, but three of them (sections 1.1-1.3) need two or three files open at once.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=5 overall=6
|
||||||
|
|
||||||
|
## Draft B, mid seat
|
||||||
|
|
||||||
|
1. **Hardest places, hardest first**
|
||||||
|
|
||||||
|
1. **`src/link-life.ts:74` `LinkLife.transition()`, with `lose()` :148, `end()` :159, and `src/session.ts:263` `apply()` / :269 `run()`.** The table depends on two hidden variables besides the phase: the `stopping` flag and the `drops` counter. `lose()` sets the phase to `down` before it calls `end()`, and that is the only thing that stops `end()` counting the same drop twice. `'bound'` while stopping goes through `lose()` and ends up in `end()`. The effect order matters: `dropLink` clears held, incoming and outgoing before `emitClose`. The phase names mislead too. The doc at :14 says "`up`: a bound socket carries requests", but the phase starts as `up` (:60) on a socket nothing has bound yet, both on a server session and on a client before its bind. I only resolved that after reading `OutgoingRequests.requestPastDrain` (outgoing-requests.ts:85), where the bind path skips the link check. The table's JSDoc helps. The transitions themselves I had to trace by hand.
|
||||||
|
2. **Shutdown, spread over `src/session.ts:237` `unbind()` / :300 `drain()`, `src/drain.ts:96` `drain()` / :70 `answeringBudget()`, `src/outgoing-requests.ts:70` `request()` / :85 `requestPastDrain()`, and `src/held-messages.ts` `sendReceipt`.** To know whether a send is refused I had to hold five things at once: `isStopping() && canCarry()`, the "refused as closed further on" path through `LinkLife.refusal()`, the receipt's route past the drain, the `setImmediate` turn between the two waits, and the fallback for `shutdownTimeout: 0`. `IdleWaiters` lives in `drain.ts` but serves `SendWindow` and `HeldMessages`, so I went to the wrong file for it once. The comments explain each step, but no single place explains the whole sequence.
|
||||||
|
3. **`src/held-messages.ts:94` `offer()` / :117 `listenerRejected()` / :154 `keep()`.** Release path 3 depends on `Session.emit` being overridden (session.ts:78) to return `false` when a listener throws. Path 2 depends on `captureRejections` routing through session.ts:106 back into a `WeakMap` lookup by object identity. The `working` count is taken from `session.listenerCount('sms')` at keep time. That is action at a distance in both directions. The numbered "six ways" comment is what made it followable.
|
||||||
|
4. **`src/client.ts:228` `bindOn()`, :263 `initialAttempts()`, :286 `keepTrying()`.** These are three layers of session creation for `fromStart`, with abort listeners added and removed at different points. The comment at :249 says `close()` has to reach `stop()` before its first await. That rule depends on `Session.drain()` calling `apply('stopping')` synchronously (session.ts:301). The comment resolved it, but it is an ordering dependency across files.
|
||||||
|
5. **`src/pdu.ts:84` `resolveShortMessage()` / :113 `resolveBody()`.** The `CodingSource` idea (which of the two bodies gets to set `data_coding`) has about six branches: empty buffer, non-empty buffer, a string that encodes to empty, the command having no `short_message`, and a string `message_payload`. I had no domain background for why the payload may override `data_coding` only when `short_message` is empty. The type comment at :74 got me about half of it.
|
||||||
|
6. **`src/reassembly.ts:188` `trim()` with `src/expiring-groups.ts:70` `weigh()`.** `weigh()` returns evicted groups, possibly including the current one, and `trim` then uses `parts.size - 1` for that one. `ExpiringGroups` enforces its limits unevenly: `max` never, `maxWeight` only in `weigh`. Its three users each handle that differently: `HeldMessages` checks the weight itself, and `DlrMerger` runs two instances (`groups` + `spent`, dlr-merger.ts:150/:165). The class comment says all this, but I needed a second pass.
|
||||||
|
7. **`src/defs/encodings.ts:162` `messageClassEncoding()` / :182 `encodingByDataCoding()` / :151 `messageClassOf()`.** This is bit arithmetic over GSM 03.38 coding groups, which I have never seen. The comments cite the spec but I can't check them. It stayed partly opaque, but it is small and self-contained.
|
||||||
|
8. **`src/outgoing-requests.ts:114` `carry()` / :141 `attempt()`.** A recursive retry that shares one link budget, where a retry happens only when `retryOnNextLink && awaitsNextLink()`. Inside `carry()`, `const held = await waitForLink()` reuses the word "held", which elsewhere means `HeldMessages`. Readable once you know the link model.
|
||||||
|
|
||||||
|
2. **Least want to modify:** `LinkLife.transition()` together with `Session.run()`. Every lifecycle path goes through it (idle timeout, socket close, unreadable stream, unbind, close, rebind). The order of effects and the `stopping`/`drops` side state are invariants that nothing in the types enforces. A wrong effect order would show up as a leaked pending request or a double `close` event somewhere far away.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:** the wire codec. `defs/types.ts`, TLV read/write and `PduFramer` are mechanical, range-checked and uniform. The same goes for `PendingRequests`, `SendWindow`, `ReconnectLoop`, the linear check chain in `send-sms.ts`, and `udh.ts`. Receipt parsing in `dlr.ts` was clearer than I expected for an unfamiliar domain.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **Needed, and cheap to find:**
|
||||||
|
- The charter's architecture list. It is accurate and maps one file to one question.
|
||||||
|
- The "GSM 7-bit is sent unpacked" section, needed for the 153/134 figures in `message.ts:129`.
|
||||||
|
- README "Bind direction" and "Receiving in depth", needed for the `data_sm` direction and for multipart being answered on arrival.
|
||||||
|
- README "Shutdown", needed to see why the drain waits on messages at all.
|
||||||
|
- **Needed, and costly:** nothing tells a newcomer what `esm_class`, `data_coding`, TON/NPI or `sar_*` are beyond scattered spec citations. I had to piece them together from the README and comments.
|
||||||
|
- **Stale prose:** `SessionOptions.shutdownTimeout` (session-options.ts:63) says "How long a drain waits for the requests already on the wire. 0 waits forever". That omits the wait on messages and the rule that 0 does not wait forever for them, which is exactly what `drain.ts` implements.
|
||||||
|
- **Told me nothing beyond the code:**
|
||||||
|
- The charter's defect table (0.4.0 history, no help reading today's code).
|
||||||
|
- The long test-fixture convention paragraph, for this read.
|
||||||
|
- The decision index titles, which I can't expand, and several of which repeat nearby code comments.
|
||||||
|
- `Injected so expiry can be exercised without a wall clock`, repeated in four option types.
|
||||||
|
- The duplicated `deliver()` doc on `OutgoingRequests` and `PendingRequests`.
|
||||||
|
- `/** Starts both timers over… */`-style restatements in `link-timers.ts`.
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
- **Navigation: 8.** Above 7, below 9. The charter's per-file map and concept-named files took me straight to the right place almost every time. The detours were `IdleWaiters` living in `drain.ts`, and receipt-sending split between `sms.ts` and `OutgoingRequests.requestPastDrain`.
|
||||||
|
- **Locality: 6.** Between 5 and 7. Collaborators are injected and the lifecycle effects are an explicit list. But `HeldMessages` and `IncomingRequests` call back into `Session` (`emit` override semantics, `listenerCount`, `close`), and correctness depends on synchronous ordering (`apply('stopping')` before the first await, the `setImmediate` in `drain`).
|
||||||
|
- **Shape: 7.** The Session hub's fan-out is wide but flat (about 9 collaborators, each small). A few names lie: the `up` phase before any bind, the `ASCII` encoding meaning GSM 03.38, the word "held" reused in `carry()`, and two different `onConnected` signatures (a `Session` in `ReconnectOptions`, a `Socket` in `ReconnectLoopOptions`).
|
||||||
|
- **Self-sufficiency: 7.** Comments give the why and the spec section at nearly every surprising line, so most units stand alone. The domain vocabulary (the `esm_class` and `data_coding` bit layouts) and the whole shutdown story needed the README beside the code.
|
||||||
|
- **Overall: 6.** Held down by Locality. The hard part (link life plus the drain) is localized and marked, but it takes rereads. A mid-level reader takes about two days to feel safe in `session`, `link-life` and `held-messages`.
|
||||||
|
- **Intrinsic difficulty:** high. An asynchronous protocol session with reconnect, a send window, reassembly and a graceful drain, on a domain the reader doesn't know. That earns no bonus here.
|
||||||
|
|
||||||
|
SCORES nav=8 loc=6 shape=7 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft B, senior seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked hardest first**
|
||||||
|
|
||||||
|
1. **The shutdown path**: `src/session.ts:237` (`unbind`), `:254` (`close`) and `:300` (`drain`); `src/drain.ts:96` (`drain`), `:70` (`answeringBudget`) and `:65` (`leftOf`); `src/outgoing-requests.ts:70` (`request`).
|
||||||
|
- To answer "what does a send do during shutdown?" I had to hold four files at once. `LinkLife.stopping` is set by `apply('stopping')`. `request()` refuses only when `isStopping() && canCarry()`. Otherwise it relies on `awaitsNextLink()` going false because `retrying()` checks `!stopping`, which I had to hunt for in `link-life.ts:132`.
|
||||||
|
- The two drain budgets differ in a way only the code shows. Messages fall back to `responseTimeout`; requests get `leftOf(deadline)`, clamped to at least 1 ms. There is also an ordering dependency on a `setImmediate` turn between the two waits (`drain.ts:108`).
|
||||||
|
- The comments are correct but terse. I resolved it after two reads, plus the README "Shutdown" section.
|
||||||
|
2. **The held-message lifecycle**: `src/held-messages.ts:94` (`offer`), `:117` (`listenerRejected`) and `:154` (`keep`); `src/session.ts:106`; `src/sms.ts:91-93` and `:151-161`.
|
||||||
|
- A hold ends in six ways, and they are spread across three files. `Session`'s `captureRejectionSymbol` reaches into `held` by identity, through a `WeakMap`. `working` is a snapshot of `listenerCount('sms')` taken when the message is kept.
|
||||||
|
- The numbered "1–6" comments are what resolved it. Without them this was action at a distance.
|
||||||
|
3. **The link state machine**: `src/link-life.ts:74` (`transition`), `:148` (`lose`) and `:159` (`end`).
|
||||||
|
- `lose()` sets `down` and then calls `end()`, which rereads `attached()` (now false). `drops` is incremented in two places.
|
||||||
|
- The initial `linkPhase = 'up'` (`:60`) contradicts the type doc at `:13-17`, which says `up` means "a bound socket carries requests". A fresh client or server socket is `up` before any bind.
|
||||||
|
- Session's `bound()` (records the bind) and LinkLife's `'bound'` event (a rebind was answered) share a name and mean different things. This stayed partly opaque until I traced `comeBackUp` (`session.ts:359`).
|
||||||
|
4. **Reassembly under the octet cap**: `src/reassembly.ts:111` (`collect`) and `:188` (`trim`), with `src/expiring-groups.ts:70` (`weigh`).
|
||||||
|
- The segment is inserted before `trim`. `weigh` can evict the current group itself, and `answered = parts.size - 1` then excludes the refused segment from the loss.
|
||||||
|
- `ExpiringGroups` enforces max, timeout and weight differently: `full` is only a flag, `weigh` evicts, `set` never does. Its own doc comments make that explicit, which resolved it.
|
||||||
|
5. **Encoding a body under `data_coding`**: `src/pdu.ts:84` (`resolveShortMessage`) and `:113` (`resolveBody`).
|
||||||
|
- `CodingSource` decides whether `short_message` or `message_payload` may overwrite `data_coding`. An empty encoded buffer flips the source to `message_payload`, and a Buffer `short_message` of length 0 does the same.
|
||||||
|
- I needed the decision titles in the charter to see why. The branches are stateful rather than hard, but I reread them three times.
|
||||||
|
6. **The client's first connect**: `src/client.ts:333` (`client`), `:286` (`keepTrying`), `:263` (`initialAttempts`) and `:228` (`bindOn`).
|
||||||
|
- There are two `ReconnectLoop`s: one outside any session for `fromStart`, and one inside each session. Each attempt builds and discards a whole `Session`.
|
||||||
|
- `bindOn` relies on `close()` reaching `stop()` before its first await (the comment at `:249`). That ordering invariant lives in `session.ts:300` (`drain` calling `apply('stopping')` synchronously).
|
||||||
|
- The comment named the ordering rule; checking it held took a look at `session.ts`.
|
||||||
|
7. **`DlrMerger.open`/`close`**: `src/dlr-merger.ts:150` and `:165`.
|
||||||
|
- `close()` means "delete, then mark spent". It runs on completion, on expiry, on eviction and on a reused base, and the delete-then-set on `spent` is only there to refresh its deadline.
|
||||||
|
- A second `ExpiringGroups<true>` used as a tombstone set is clever but not named as one. The class doc resolved it.
|
||||||
|
8. **Server hook composition**: `src/server.ts:208` (`handleRequest`) with `src/incoming-requests.ts:89` (`handle`) and `:147` (`unhandled`).
|
||||||
|
- What happens to a rebind or a pre-bind `unbind` is decided half in each file, through `false` return values. `boundAs === undefined` makes `bindAllows` return true.
|
||||||
|
- I resolved it by reading both. A smaller cost of the same kind: `pdu.ts:249` passes `sm_length` as the `length` argument to every wire type's `read`, and only `buffer` uses it. That implicit coupling depends on wire order.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify: `OutgoingRequests`** (`src/outgoing-requests.ts:70-168`)
|
||||||
|
- It has three entry points: `request`, `requestPastDrain` and `requestOnCurrentLink`. Each skips a different subset of checks (drain refusal, waiting for a link, the window).
|
||||||
|
- Every sender in the library picks one of them by name: `enquire_link`, `sendSms`, receipts from `sendDlr`, bind and unbind.
|
||||||
|
- The retry recursion in `carry` depends on `LinkLife.awaitsNextLink()`, `pending.settle` ordering, and window release in `finally`.
|
||||||
|
- Whether a request may be resent (goal 2) is decided here, and it hinges on the `retryOnNextLink` flag. A wrong edit silently duplicates billed traffic.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy**
|
||||||
|
- The codec: `defs/types.ts` wire types, `PduFramer`, and `parseTlvs`/`writeTlvs` with the typed `Tlvs`.
|
||||||
|
- GSM 03.38 escaping, `encodingByDataCoding`, receipt text parsing (`dlr.ts`), `sms-id.ts` normalisation, `ReconnectLoop` backoff, and `udh.ts` IE walking.
|
||||||
|
- Each is self-contained, total, and commented at the exact surprising line.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **Needed**:
|
||||||
|
- The README "Shutdown" and "Sends and the link" sections, to confirm the drain semantics I was reverse-engineering. They cost a scroll through a 773-line README.
|
||||||
|
- The charter's decision index. The titles hinted at intent ("One owner decides whether a link can carry a request, and a bind is what makes it one"), but I was forbidden from `decisions.md`, so several stayed claims I could only check against code. The one above contradicts LinkLife starting `up`.
|
||||||
|
- The GSM-unpacked section in the charter, to trust `segmentUnits` (`message.ts:343`).
|
||||||
|
- **Defaults live in five places**: `client.ts:45`, `server.ts:403`, `session-options.ts:74`, `reconnect-loop.ts:5` (`backoffDefaults`) and `reassembly.ts:45` (`defaultMaxOctets`, duplicated by `maxHeldOctets`). "Where is the default of X" is a grep.
|
||||||
|
- **Told me nothing new**:
|
||||||
|
- The charter's architecture table mostly restates file names.
|
||||||
|
- Its long paragraph on test conventions is irrelevant to `src/`.
|
||||||
|
- `session.ts:201` ("Sends a request and resolves with the peer's response") restates the code.
|
||||||
|
- `reconnect-loop.ts:47` ("Read through a method: stop() can land while an attempt is awaiting") hides its real reason: it defeats TS narrowing.
|
||||||
|
- `client.ts:101` and `reconnect-loop.ts:84` and `:140` re-implement `errorFrom` inline.
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
|
||||||
|
The problem's intrinsic difficulty is high: an interop-heavy protocol, reconnect, and correct-accounting shutdown. It gets no bonus here.
|
||||||
|
|
||||||
|
- **Navigation: 7.** Against "Predictable": file names and the charter's table put a symptom in the right file first try. What keeps it from 8 is that defaults and the shutdown rules are spread across five files each.
|
||||||
|
- **Locality: 5.** Against "Honest middle": LinkLife's phase and generation are read from Session, OutgoingRequests, IncomingRequests and HeldMessages. Understanding shutdown or a held message means holding 4–5 files and a synchronous-ordering invariant.
|
||||||
|
- **Shape: 6.** Between 5 and 7: most names tell the truth. The named lies are the `up` phase on an unbound socket, `'ASCII'` for GSM 03.38, "bound" meaning two things, `DlrMerger.close` meaning "tombstone", and three near-synonym request methods. Session's constructor fans out to nine collaborators.
|
||||||
|
- **Self-sufficiency: 7.** Against "Predictable": the one-line why-comments at the surprising lines resolved nearly every question. I needed the README only for the drain semantics, and the decision titles were claims I could not verify inside the code.
|
||||||
|
- **Overall: 6.** Capped by locality at 5 + 1. The hard parts are marked but not localized: they are the problem's own difficulty, spread across collaborators that share link state.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=5 shape=6 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft B, architect seat
|
||||||
|
|
||||||
|
**Comprehension panel: Architect, inherited. @larvit/smpp, draft-b, whole project**
|
||||||
|
|
||||||
|
Process note: I read `AGENTS.md` in the same call as `README.md` during step 1, so the map below was not formed from the README and file tree alone. I opened no test file.
|
||||||
|
|
||||||
|
## 1. Map from the README and the file tree (verbatim)
|
||||||
|
|
||||||
|
Top-level areas I believe exist:
|
||||||
|
1. **Public surface**: `index.ts`.
|
||||||
|
2. **Endpoints**: `client.ts` (connect, bind, reconnect-from-start) and `server.ts` (listener, auth, bind answering).
|
||||||
|
3. **Session core**: `session.ts`, `session-options.ts`, `bind-direction.ts`.
|
||||||
|
4. **Link lifecycle**: `link-life`, `link-timers`, `reconnect-loop`, `pdu-transport`, `drain` (shutdown?).
|
||||||
|
5. **Outbound requests**: `outgoing-requests`, `pending-requests`, `send-window`, `send-sms`, `unanswered-error`.
|
||||||
|
6. **Inbound messages**: `incoming-requests`, `sms.ts` (the handle), `held-messages`, `reassembly`, `concat`, `udh`, `message-body`.
|
||||||
|
7. **Receipts**: `dlr`, `dlr-merger`, `sms-id`.
|
||||||
|
8. **Codec**: `pdu`, `pdu-framer`, `pdu-refusal`, `retained-pdu`, and `defs/*` as pure spec tables.
|
||||||
|
9. **Text**: `message.ts` (encode, split, `smppTime`) and `defs/encodings`.
|
||||||
|
10. **Plumbing**: `result`, `log`, `error-from`, `uuid`, `expiring-groups`.
|
||||||
|
|
||||||
|
Names that do not give their purpose:
|
||||||
|
- `drain.ts`
|
||||||
|
- `retained-pdu.ts`
|
||||||
|
- `expiring-groups.ts`
|
||||||
|
- `error-from.ts`
|
||||||
|
- `link-life` vs `link-timers`
|
||||||
|
- `outgoing-requests` vs `pending-requests` vs `send-window`: three names for "requests we sent"
|
||||||
|
- `held-messages` vs `reassembly`: both "held" inbound state
|
||||||
|
|
||||||
|
Expected from the README but not there: the goal-9 store interface. The README says it has not shipped, so that costs nothing. `interop-tests/` and `benchmarks/` sit outside `src`/`test`.
|
||||||
|
|
||||||
|
## 2. Where the map was wrong, and what each correction cost
|
||||||
|
|
||||||
|
| Correction | Cost |
|
||||||
|
| --- | --- |
|
||||||
|
| `defs/` is not just tables. `defs/types.ts` (684 lines) and `defs/tlvs.ts` are half the wire codec: read, write and size for every field, plus the TLV stream. `pdu.ts` is only the envelope and the body rules. | Moderate. "Where is a field written" lands in `defs`, not `pdu`. |
|
||||||
|
| `drain.ts` also holds `IdleWaiters`, which `SendWindow` and `HeldMessages` import. The send window depends on the shutdown module. | Low, but it breaks the "which way do imports point" picture. |
|
||||||
|
| `held-messages` is not a reassembly buffer. It is the inbound messages the *application* has not answered yet. | Moderate: a name-level misread. |
|
||||||
|
| `link-life` is liveness plus a queue: the phase state machine, and where a request waits for the next link. | Low. |
|
||||||
|
| Bind handling is not in the session. `server.ts` does it through the `onRequest` hook (`handleRequest`), and `client.ts` has its own `bind()`. | Moderate: two homes for one protocol step. |
|
||||||
|
| `udh.ts` reads UDHs. Writing one is hand-rolled in `message.ts:114` (`0x05,0x00,0x03,…`), and the outbound reference counter `ConcatReference` sits in `udh.ts`. | Low. |
|
||||||
|
| `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`. | Low. |
|
||||||
|
| `session.ts` is thinner than I expected (424 lines): a coordinator, not a god class. | Pleasant surprise. |
|
||||||
|
|
||||||
|
## 3. Fan-out, level by level
|
||||||
|
|
||||||
|
- **L0, the README:** about 10 areas. Fine.
|
||||||
|
- **L1, `src/`:** 37 flat files plus `defs/` (7). **This is the worst level.** Nothing in the layout groups the 10 areas, so only filenames and the `AGENTS.md` list stand in for directories.
|
||||||
|
- **L2, `session.ts`:** 10 collaborators (DlrMerger, HeldMessages, IncomingRequests, LinkLife, LinkTimers, OutgoingRequests, PduTransport, ReconnectLoop, ConcatReference, drain), plus 6 LinkEffects and 5 LinkEvents. At the edge but legible.
|
||||||
|
- **L3:**
|
||||||
|
- `IncomingRequests`: 8 (Reassembler, DlrMerger, HeldMessages, Session, bind-direction, dlr, concat, sms-id).
|
||||||
|
- `OutgoingRequests`: 4.
|
||||||
|
- `HeldMessages`: 5.
|
||||||
|
- `server.ts`: about 6.
|
||||||
|
- `client.ts`: about 8 local functions and a second `ReconnectLoop`.
|
||||||
|
- **`defs/`:** 7. Fine.
|
||||||
|
|
||||||
|
## 4. Names
|
||||||
|
|
||||||
|
**One name over two concepts:**
|
||||||
|
- **"unanswered"** means inbound messages the application has not answered (`drain.ts:52` `messagesUnanswered`, README "Unanswered messages"). It also means our requests the peer did not answer (`UnansweredError`, `SendSmsResult.unanswered`, `SendDlrResult.unanswered`). The drain waits on both kinds in one function.
|
||||||
|
- **"held"** means `HeldMessages` (inbound, unanswered by the app). It also means a request waiting for a link: `link-life.ts:191` "holding a request until a link is back", and `outgoing-requests.ts:119` `const held = await waitForLink()`.
|
||||||
|
- **`answered`** in `createSms` (`sms.ts:81`) is a mutable `{ smsId }` box, while `handlers.answered` is the release callback. They are two things on adjacent lines.
|
||||||
|
|
||||||
|
**Names that mislead:**
|
||||||
|
- `EncodingName 'ASCII'` is GSM 03.38.
|
||||||
|
- `drain.ts` houses the general `IdleWaiters`.
|
||||||
|
- `defs/` houses the codec.
|
||||||
|
- `ExpiringGroups` expires nothing itself. Its own doc at `expiring-groups.ts:19` says owners sweep and only `weigh()` evicts.
|
||||||
|
- `session-options.ts:63` documents `shutdownTimeout` as "how long a drain waits for the requests already on the wire". It also bounds the messages half. That comment is false on exactly the 3am path.
|
||||||
|
|
||||||
|
**One concept, many homes:**
|
||||||
|
- **Defaults live in five places:** `client.ts:45`, `server.ts:403` (idleTimeout 40 000 here vs 2 × enquireLink in the client), `session-options.ts:74`, `reconnect-loop.ts:5` `backoffDefaults`, and `reassembly.ts` `defaultMaxOctets`. The last duplicates `defaults.maxHeldOctets` (same 64 MiB).
|
||||||
|
- **Two near-identical collectors:** `send-sms.ts:274` `collectSent` and `sms.ts:188` `collectReceipt`.
|
||||||
|
- **Five concat names:** `Concat`, `ConcatInfo`, `concatOf`, `concatInfo`, `ConcatReference`, spread over `concat.ts` and `udh.ts`.
|
||||||
|
|
||||||
|
## 5. What I would restructure, ranked
|
||||||
|
|
||||||
|
1. **Group `src/` into about five directories:** `link/`, `outbound/`, `inbound/`, `codec/`, `receipts/`. The seams already exist in the imports; only the layout hides them.
|
||||||
|
2. **Give defaults one home:** a single `defaults` module, with the client/server differences expressed as named overrides.
|
||||||
|
3. **Split "unanswered" into two words**, for example "unreleased" for app-side messages and "unanswered" for peer-side requests. Move `IdleWaiters` out of `drain.ts`.
|
||||||
|
4. **Put UDH read and write in one file,** together with `ConcatReference`, and move `decodeSegments` next to `messageOctets`.
|
||||||
|
5. **Rename `defs/types.ts`** to what it is, the wire field codec, or move it beside `pdu.ts`.
|
||||||
|
|
||||||
|
**What the structure gets right:**
|
||||||
|
- `LinkLife.transition()` is one explicit table returning effects, and `Session.run` (`session.ts:269`) is the only interpreter of them.
|
||||||
|
- Collaborators take narrow option objects.
|
||||||
|
- The result-everywhere rule is applied uniformly.
|
||||||
|
- `drain()` names its two halves, and `report()` logs which half was left over.
|
||||||
|
- Comments cite the SMPP section or the operator that forced each odd rule.
|
||||||
|
|
||||||
|
## 6. The 3am question
|
||||||
|
|
||||||
|
A graceful shutdown hangs until `shutdownTimeout` although `sendResp()` was called on everything.
|
||||||
|
|
||||||
|
**Route, cold:** `Session.close` (`session.ts:254`) → `Session.drain` (`session.ts:300`) → `drain()` (`drain.ts:96`). That took about 3 minutes, because the filename and the function name agree. Deciding which half took about 10 more minutes:
|
||||||
|
- With every `sendResp` done, `HeldMessages.idle` should settle. Release happens at `held-messages.ts:170` via `sms.ts:159`.
|
||||||
|
- So the right unit is the second half: `OutgoingRequests.idle` (`outgoing-requests.ts:109`) → `SendWindow.idle`/`unfinished` (`send-window.ts:65`).
|
||||||
|
- That means a request of ours is still in flight. Typically it is the `sendDlr()` receipts that `requestPastDrain` let through, or a heartbeat `enquire_link` the peer is not answering.
|
||||||
|
- Each such request is bounded by `responseTimeout` (30 s), which is longer than `shutdownTimeout` (5 s).
|
||||||
|
|
||||||
|
**Second suspect:** `sendResp` returned `err` because the write failed, and the application ignored it. The message is then never released (`sms.ts:159` releases only on success).
|
||||||
|
|
||||||
|
The log lines `drain - shutting down with requests unfinished` and `drain - shutting down with messages unanswered` separate the two. The false comment at `session-options.ts:63` costs a detour.
|
||||||
|
|
||||||
|
**Where it rots first:**
|
||||||
|
- **`HeldMessages`:** six numbered exits. A listener count taken via `listenerCount('sms')` at `keep()` (`held-messages.ts:154`) and decremented through `captureRejections` routed from `session.ts:97`. A `WeakMap` keyed on the handle's identity, and a back-reference to the half-constructed `Session` (`session.ts:137`).
|
||||||
|
- **The link-generation checks:** repeated in `sms.ts` (`lostLink`), `incoming-requests.ts:90/97` and `held-messages.ts:95`. Each is a local copy of one invariant.
|
||||||
|
|
||||||
|
**Where the next two features land:**
|
||||||
|
- **Goal-9 store:** behind `ExpiringGroups`, whose three owners (`DlrMerger`, `Reassembler`, `HeldMessages`) each use a different subset of its semantics: sweep callbacks, `weigh()` eviction, `full`. A store has to replicate all three contracts.
|
||||||
|
- **Per-PDU rate-limit hook (goal 7):** `OutgoingRequests.carry` (`outgoing-requests.ts:114`), between `window.acquire` and `attempt`. That place is clean and local.
|
||||||
|
- A custom alphabet would instead ripple through the closed `EncodingName` union: `message.ts:14` `segmentUnits`, `dataCodingByEncoding`, and the `send-sms` checks.
|
||||||
|
|
||||||
|
## 7. Hardest places, ranked
|
||||||
|
|
||||||
|
1. **`src/held-messages.ts:40`, `HeldMessages`:** `offer` :94, `listenerRejected` :117, `keep` :154, `release` :170. It has six exits, identity-keyed release, a listener-count countdown, and is coupled to the `Session` emitter.
|
||||||
|
2. **`src/outgoing-requests.ts:70/85/101`, `request` / `requestPastDrain` / `requestOnCurrentLink`:** three entry points that differ in which gates they skip (drain refusal, link wait, window). The bind bypass is buried inside `requestPastDrain`, and the `isStopping() && canCarry()` gate needs a second read.
|
||||||
|
3. **`src/link-life.ts:74`, `LinkLife.transition`,** with `lose` :148 and `end` :159. A `stopping` flag orthogonal to the phase, `'bound'` while stopping turning into a loss, and effect order that matters in `Session.run` (`session.ts:269`, where `dropLink` clears four stores).
|
||||||
|
4. **`src/drain.ts:70/96`, `answeringBudget` and `drain`:** `0` means forever except for the messages half, plus a `setImmediate` turn whose job is to catch a receipt issued right after the last answer.
|
||||||
|
5. **`src/reassembly.ts:188`, `Reassembler.trim`,** with `collect` :111. The eviction arithmetic (`parts.size - 1` when the current group is itself evicted) is correct but has to be derived.
|
||||||
|
6. **`src/expiring-groups.ts:19/70`, `ExpiringGroups.weigh`:** it evicts, `set` does not, and owners must sweep. Its callers' correctness depends on remembering which.
|
||||||
|
7. **`src/pdu.ts:84/113`, `resolveShortMessage` / `resolveBody`:** which field the `data_coding` describes, and when detection may overwrite it.
|
||||||
|
8. **`src/client.ts:263/286`, `initialAttempts` / `keepTrying`:** a second `ReconnectLoop`, with a fresh `Session` per attempt, for `fromStart`.
|
||||||
|
|
||||||
|
**Single unit I would least want to modify:** `HeldMessages` (`src/held-messages.ts`).
|
||||||
|
|
||||||
|
## 8. Intrinsic difficulty
|
||||||
|
|
||||||
|
This is an SMPP session layer with reconnect, graceful drain, bounded reassembly, receipt merging and a hand-rolled wire codec. The problem itself is moderately high in difficulty, and gets no bonus in the scores.
|
||||||
|
|
||||||
|
## 9. Scores
|
||||||
|
|
||||||
|
- **Navigation 7:** matches the "predictable" anchor. Descriptive filenames and function names took me from the symptom to `drain()` in minutes, and `report()` logs which half stalled. The flat 37-file `src/` and the false `shutdownTimeout` comment keep it from 8.
|
||||||
|
- **Locality 6:** between "honest middle" and "predictable". `HeldMessages` and `IncomingRequests` call back into `Session` (emit, `sendReturn`, `close`), the link-generation invariant is copied into three places, `Session.run` effect order is load-bearing, and defaults live in five files.
|
||||||
|
- **Shape 6:** between the anchors. L2 and L3 fan-out is bounded and most names tell the truth. `src/` fans out to 37 files, and "unanswered", "held", `'ASCII'`, `drain.ts` and `defs/` each name something other than what they hold.
|
||||||
|
- **Self-sufficiency 7:** the "predictable" anchor. Invariants are stated at the code (ExpiringGroups' contract, the six exits, SMPP section citations), and I did not need a second document open. It misses 8 on one false option comment and a few budget rules (`answeringBudget`) that need rereading.
|
||||||
|
- **Overall 6:** capped at the lowest dimension plus one (7). It sits at 6 because the hardest code (the held-message flow and outbound gating) is exactly where locality is weakest.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=7 overall=6
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
# Round 2: drafts C and D
|
||||||
|
|
||||||
|
## Draft C, junior seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked hardest first**
|
||||||
|
|
||||||
|
1. **`src/session.ts:336-404` (`drain`, `dropSocket`, `linkLost`, `end`), `comeBackUp` at `:303`, and `src/session/link-life.ts:17-82` (`LinkLife`'s phase plus its predicates).** Link state is a four-value `Phase` plus a separate `stopped` flag. Seven predicates read it: `isAttached`, `isUp`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`. On top of that, `OutgoingRequests.canCarry()` adds `!sock.destroyed`. To follow `comeBackUp`'s `!this.link.retrying() || !this.link.isUp()` (`:319`) or `drain`'s two `canCarry()` checks, I had to hold every phase transition at once. `linkLost` is order-dependent ("Read first: a `disconnected` listener may close() the session"), so a synchronous listener re-enters the session in the middle of the method. The comments resolved each line on its own. The whole state machine never became clear to me; I would need to draw it.
|
||||||
|
|
||||||
|
2. **`src/session/outgoing-requests.ts:83-150` (`request`, `carry`, `attempt`).** The three lanes, the `for(;;)` retry loop, `window.release()` in a `finally`, and `pending.wait()` registered before `write()` all interact. The loop-exit comment at `:118` ("Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins") took three rereads. The `Lane` doc comment at `:20-26` rescued the lanes. The retry exit stayed half-opaque.
|
||||||
|
|
||||||
|
3. **`src/session/incoming-requests.ts:103-271` (`handle`, `route`, `onDelivery`, `onMessage`, plus `refusedSegmentStatus` at `:28`).** This is where the domain costs the most. `data_sm` routes by `carriedAs`. `deliver_sm` might be a receipt or a message, and `onDelivery` falls through to `onMessage`. The status codes come as a family: `ESME_RX_P_APPN`, `ESME_RX_T_APPN`, `ESME_RTHROTTLED`, `ESME_RINVTLVVAL`, `ESME_RINVESMCLASS`. The `arrivedOn` socket-identity check at `:111` is state compared across an `await`. The flow reads cleanly, but I only knew why each status was right after reading README "Receiving in depth" and "Server in depth".
|
||||||
|
|
||||||
|
4. **`src/messages/reassembly.ts:110-206` (`Reassembler.collect`, `trim`) with `src/messages/expiring-groups.ts:70-88` (`weigh`).** `weigh()` can evict the group currently being added to. `trim` then counts `parts.size - 1` for that group, because the segment just added is refused and so is not lost. The contract is split across two classes: `ExpiringGroups` "enforces neither max nor timeout itself", so every owner must remember to call `full` or `takeExpired`. It resolved after reading the `ExpiringGroups` doc comments. Action at a distance, but it is marked.
|
||||||
|
|
||||||
|
5. **`src/messages/dlr.ts:157-238` (`messageType`, `receiptStatus`, `dlrFromPdu`).** A four-value `MessageType` is derived from `esm_class` bits and then from a TLV. The TLV state and the body's state take precedence over each other in different ways. `statusId` falls back to `UNKNOWN`, and an `unmarked` PDU without both an id and a state is not a receipt. The comments cite spec sections (5.3.2.26, Appendix B) that I cannot open. The README `Dlr` field table is what finally made the output shape make sense.
|
||||||
|
|
||||||
|
6. **`src/client.ts:219-322` (`bindOn`, `initialAttempts`, `keepTrying`).** There are two routes into `ReconnectLoop`: the session's own, and a second one here that builds a fresh `Session` per attempt. There is closure state (`lastErr`, `settled`) and abort-listener bookkeeping. The comment at `:241` ("close() must reach the loop's stop() before its first await") depends on how `Session.close` is ordered inside, in another file. It stayed partly opaque.
|
||||||
|
|
||||||
|
7. **`src/wire/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`).** `CodingSource` decides whether `short_message` or `message_payload` owns `data_coding`. The cases are Buffer or string, empty or not, crossed with a string TLV being present. That is four or more branches returning objects that are almost the same. The `CodingSource` doc comment helped, but only after I had read `messageOctets()` in `message-body.ts`.
|
||||||
|
|
||||||
|
8. **`src/defs/encodings.ts:151-190` (`messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`).** Bit masking over GSM 03.38 coding groups, which I had no context for. `messageClassEncoding` has a `//` comment and a `/** */` comment stacked on top of it that say different things. It stayed opaque, and I would trust the tests over my reading.
|
||||||
|
|
||||||
|
2. **The unit I'd least want to modify:** `Session.linkLost`, `dropSocket` and `end` (`src/session.ts:366-404`) together with `LinkLife`. They are re-entered from the transport (close, error, unreadable), from `LinkTimers.onIdle`, from `comeBackUp`, from `unbind` and `close`, and synchronously from application listeners (`disconnected`, `close`). The correctness depends on call order and on idempotence guards (`drop()` returning false, `end()` returning false). None of that is visible at any single call site.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:**
|
||||||
|
- The codec in `defs/types.ts`: 684 lines, but it is one pattern repeated (read, size, write, all returning Results).
|
||||||
|
- `PduFramer`.
|
||||||
|
- The no-throw `Result` convention, which is applied the same way everywhere.
|
||||||
|
- `send-sms.ts`: `checkOptions` is a flat chain of guards.
|
||||||
|
- `bind-direction.ts`.
|
||||||
|
- The folder layout. The AGENTS.md architecture map matched the files one to one, and a one-line purpose per file made cold navigation fast.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **What I needed and what it cost:**
|
||||||
|
- The basic domain vocabulary: ESME vs SMSC/MC, bind types, what `deliver_sm` doubles as, `esm_class`, `data_coding`, UDH vs `sar_*`, `registered_delivery`. There is no glossary anywhere. I rebuilt it from README "Receiving in depth", "Delivery receipts" and "Bind direction", which cost a pass over a 764-line README with a lot of jumping.
|
||||||
|
- The AGENTS section "GSM 7-bit is sent unpacked", which was essential for `segmentUnits` in `message.ts`.
|
||||||
|
- Many code comments cite SMPP section numbers with no summary, so they are pointers I could not follow.
|
||||||
|
- The AGENTS decision index names choices, for example "Every request leaves through one `request()`, in one of three lanes", whose reasoning is in `docs/decisions.md`, which was out of bounds. The titles alone helped a little.
|
||||||
|
- I did not open any test.
|
||||||
|
- **Prose that added nothing the code didn't already say:**
|
||||||
|
- The seven `declare` listener lines in each emitter class.
|
||||||
|
- `OutgoingRequests.deliver` ("False means nothing was") and `PendingRequests.deliver`, both restating their code.
|
||||||
|
- README's "Everything exported" table, which repeats `index.ts`.
|
||||||
|
- The AGENTS Conventions paragraph about test fixtures, for reading `src/`.
|
||||||
|
- The 0.4.0 defect table, which is history and says nothing about the current code's structure.
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
- **Navigation: 7.** It sits at "predictable": the `session/`, `messages/`, `wire/`, `defs/` split plus the per-file map in AGENTS.md got me from a symptom to a file on the first try. It stays below 8 because concatenation logic is split three ways (`concat.ts`, `udh.ts`, `reassembly.ts`), refusal statuses are split between `pdu-refusal.ts` and `incoming-requests.ts`, and `udh.ts` holds `ConcatReference`.
|
||||||
|
- **Locality: 6.** It is between "honest middle" and "predictable". The hard parts are marked, but `IncomingRequests` holds the `Session` and calls back into it (`emit`, `sendReturn`, `close`, `sock`, `linkEnd`). `LinkLife` is shared by `Session` and `OutgoingRequests`. `ExpiringGroups` depends on its owners to sweep. `linkLost` is re-entrant through listeners.
|
||||||
|
- **Shape: 6.** Files are small and fan-out is bounded, but some names mislead:
|
||||||
|
- The GSM7 codec is called `ascii`.
|
||||||
|
- `ExpiringGroups<true>` is used as a "spent" set.
|
||||||
|
- `linkLost()` means something different in `Session`, `OutgoingRequests` and `IncomingRequests`.
|
||||||
|
- `refusal()` returns an `Error` on `LinkLife` and an `ErrorName` on `IncomingRequests`.
|
||||||
|
- `IdleWaiters.settle()` wakes waiters whatever the count reads.
|
||||||
|
- **Self-sufficiency: 5.** Honest middle. The comments are dense and often state invariants. But for a reader with no SMPP background, the domain terms and bare spec citations mean the README has to stay open beside `incoming-requests.ts`, `dlr.ts` and `encodings.ts`.
|
||||||
|
- **Overall: 6.** Capped at self-sufficiency plus one. The layout and conventions are clearly cared for; what costs a junior is the lifecycle state machine and the unexplained domain.
|
||||||
|
- **Intrinsic difficulty:** high. It is an asynchronous protocol session with reconnect, drain, windowing and reassembly under hostile input. That gets no bonus in the scores above.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=5 overall=6
|
||||||
|
|
||||||
|
## Draft C, mid seat
|
||||||
|
|
||||||
|
1. **Hardest places, hardest first**
|
||||||
|
|
||||||
|
1. **`src/session.ts:303-404`: `Session.comeBackUp` / `drain` / `dropSocket` / `linkLost` / `end`.** Whether the link is alive is held in four places:
|
||||||
|
- `LinkLife.phase`, which is `binding`, `up`, `down` or `ended`
|
||||||
|
- `LinkLife.stopped`
|
||||||
|
- `ReconnectLoop.halted`
|
||||||
|
- `transport.sock.destroyed`
|
||||||
|
|
||||||
|
Order matters in several spots, and only comments say so. `linkLost` reads `retrying()` before the drop because a `disconnected` listener may call `close()`. `comeBackUp` checks `!retrying() || !isUp()` after `bind()`. That only makes sense once you find that `bind()` reaches `session.bound()`, which calls `link.open()` out of sight. `canCarry()` (`outgoing-requests.ts:58`) asks both `link.isUp()` and `sock.destroyed`, and nothing explains why both are needed. The comments helped, but I had to trace the state by hand and it is still not fully clear to me.
|
||||||
|
2. **`src/session/outgoing-requests.ts:83-150`: `OutgoingRequests.request` / `carry` / `attempt`.** The loop in `carry` has three waits: the link budget, a window slot, and the response. The window slot is released in a `finally`, and a retry is allowed only when `attempt.retry && link.awaitsNextLink()`. You need LinkLife's phase logic in your head (`link-life.ts:71-82`) to see why the loop ends. The comment "the loop spins" warns about it but does not explain it. The three lanes are well documented in the `Lane` type's comment (line 25). This resolved, slowly.
|
||||||
|
3. **`src/wire/pdu.ts:84-136`: `resolveShortMessage` / `resolveBody`.** The code decides whether `short_message` or `message_payload` owns `data_coding`. An empty Buffer means `message_payload`, and a string body gets encoded and can overwrite `data_coding`, but only for one of the two sources. I had no domain context and read it three times. The `CodingSource` comment helped, and the README "Building" bullets confirmed what it is meant to do.
|
||||||
|
4. **`src/session/incoming-requests.ts:103-271`: `handle` / `route` / `onMessage` / `refusedSegmentStatus`.** A `data_sm` becomes `submit_sm` or `deliver_sm` depending on `linkEnd` (via `standsInFor`). A `deliver_sm` that is not a receipt falls through to `onMessage`. The status codes (`ESME_RX_T_APPN`, `ESME_RTHROTTLED`, `ESME_RINVTLVVAL`, `ESME_RINVESMCLASS`) are domain rules I cannot check. The class also calls back into `Session` through `sock`, `emit`, `sendReturn` and `close`. The socket-identity check after the `onRequest` await was clear once the comment was read. The status choices stayed opaque; I trusted README "Receiving in depth".
|
||||||
|
5. **`src/messages/reassembly.ts:110-206` with `expiring-groups.ts:70-88`: `Reassembler.collect` / `trim` and `ExpiringGroups.weigh`.** The rules are split between the two classes:
|
||||||
|
- ExpiringGroups enforces neither max nor timeout, and its owners must.
|
||||||
|
- `set()` resets weight to zero.
|
||||||
|
- `weigh()` may evict the key you are weighing.
|
||||||
|
- `trim` works out `answered = parts.size - 1` for the current group.
|
||||||
|
|
||||||
|
The comments state each rule, but I needed all of them at once. This resolved.
|
||||||
|
6. **`src/messages/dlr-merger.ts:105-185`: `DlrMerger.collect` / `open` / `spend`.** There are two stores (`groups` and `spent`), and `spend()` is the exit for four different situations: completion, expiry, reuse and eviction. The class docblock and the `severity` comment explain it. This resolved.
|
||||||
|
7. **`src/defs/encodings.ts:162-190`: `messageClassEncoding` / `encodingByDataCoding`.** This is bit-level decoding of `data_coding`. The comments say which bits are which but not the table behind them. The code is short, but it stayed opaque without the GSM 03.38 spec.
|
||||||
|
8. **`src/client.ts:220-322`: `bindOn` / `initialAttempts` / `keepTrying`.** A second `ReconnectLoop` is built outside the session for `fromStart`. `bindOn` depends on `close()` reaching `stop()` before its first await, which the comment at line 241 states. There are also two abort listeners with different lifetimes. This resolved with effort.
|
||||||
|
|
||||||
|
I did not open any tests.
|
||||||
|
|
||||||
|
2. **Unit I would least want to modify:** the session lifecycle cluster, `Session.linkLost` / `dropSocket` / `end` / `comeBackUp` together with `LinkLife`. Every change there interacts with the reconnect loop's own stopped flag, with the drain's `canCarry()` checks before and after it waits, and with whatever an event listener does during `emit`. Tests would be the only way to know a change is safe.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:**
|
||||||
|
- `PduFramer`
|
||||||
|
- the parse path in `pduToObj`/`parsePdu`
|
||||||
|
- `PendingRequests`
|
||||||
|
- `SendWindow`
|
||||||
|
- `ReconnectLoop` backoff
|
||||||
|
- the listener guards that stop an application listener's throw or rejection from escaping (the no-throw rule)
|
||||||
|
- the `defs/` tables
|
||||||
|
- `defs/types.ts`, which is 684 lines but repetitive and predictable
|
||||||
|
|
||||||
|
The rule that nothing throws makes every call site easy to read.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **Needed:**
|
||||||
|
- README "Shutdown" and "Sends and the link", to understand the lanes and the drain.
|
||||||
|
- README "Receiving in depth", for which way a `data_sm` goes and the throttle statuses.
|
||||||
|
- The AGENTS architecture map, which is accurate and was the best way in.
|
||||||
|
- The AGENTS decision index lines, such as "Every message is answered on arrival" and "One owner decides whether a link can carry a request". They gave intent cheaply because each is one line.
|
||||||
|
- **Cost to find:** low, because the map and the index point to the right places. The code's references to spec sections (e.g. "SMPP 3.4 5.2.19") assume a document I do not have.
|
||||||
|
- **One false claim:** AGENTS says "`wire` uses `defs`, `messages` uses `wire`", but `wire/pdu.ts:10` imports `decodeMessage` and `encodeBody` from `messages/message.ts`. So wire and messages depend on each other.
|
||||||
|
- **Told me nothing:**
|
||||||
|
- the AGENTS 0.4.0 defect table (history, not needed to read the current code)
|
||||||
|
- most of the test-convention prose in AGENTS
|
||||||
|
- the README feature bullets
|
||||||
|
- one-line docstrings that restate the method name, such as `isStopped` and `get sock`
|
||||||
|
- **Duplicated code:** `quoted()` is duplicated in `bind-direction.ts` and `session-options.ts`. `collectSent` and `collectReceipt` are near-copies.
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
- **Navigation: 7 (Predictable).** The AGENTS file map matches the layout, and the `session/` / `messages/` / `wire/` grouping took me straight to the right file. It falls short of 9 because answering a request is split across `server.handleRequest`, `IncomingRequests.unhandled` (`ESME_RALYBND`) and `Session.refuse`.
|
||||||
|
- **Locality: 6 (between 5 and 7).** The collaborators are small and injected. But link liveness is spread over `LinkLife`, `ReconnectLoop.halted` and `sock.destroyed`, and three comments carry order rules: "Read first", "must reach stop() before its first await", and "the loop spins". `IncomingRequests` also reaches back into `Session`.
|
||||||
|
- **Shape: 7 (Predictable).** Fan-out at each level is bounded, and the `Session` constructor wires seven named collaborators. Some names mislead:
|
||||||
|
- the GSM codec is called `ascii` (`encodings.ts:45`)
|
||||||
|
- `idle` means three different things: `IdleWaiters`, `ExpiringGroups.idle()` and the `LinkTimers.idle` timer
|
||||||
|
- the near-synonyms `stop`, `end`, `close`, `release`, `linkLost` and `dropSocket` blur which one is final
|
||||||
|
- **Self-sufficiency: 7 (Predictable).** Almost every non-obvious branch carries a one-line reason, often with a spec section. The lanes, the drain and the refusal statuses still needed the README open beside the code.
|
||||||
|
- **Overall: 6.** It is capped by Locality. The lifecycle corners are marked, but they are not contained.
|
||||||
|
|
||||||
|
The problem is hard in itself: a protocol full of peer quirks, plus async lifecycle with reconnect and drain. That gets no bonus here.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=7 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft C, senior seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked**
|
||||||
|
|
||||||
|
1. **`src/session.ts:303-404`, the `Session` lifecycle: `comeBackUp`, `drain`, `stop`, `dropSocket`, `linkLost`, `end`.** Whether the session is still alive is spread across three places: `LinkLife`'s phase and its separate `stopped` flag, `ReconnectLoop.halted`, and `link.end()`. To follow any one of these methods I had to hold all three. `comeBackUp:319` tests `!retrying() || !isUp()`. That only makes sense once you know `isUp()` got set as a side effect: the `onConnected` callback in `client.ts:bind` calls `session.bound()`, which calls `link.open()`. The ordering is load-bearing in several places. `linkLost:379` has to read `retrying()` before the drop. `end()` calls `dropSocket()`, which is also a guard. `client.ts:241` says "close() must reach the loop's stop() before its first await". The comments at the call sites marked each ordering rule. None of them explained why the whole thing is split this way. It stayed expensive to read.
|
||||||
|
2. **`src/session/outgoing-requests.ts:83-121`, `request` / `carry`, together with `src/session/link-life.ts:34-186`.** `LinkLife` exposes six overlapping predicates: `isAttached`, `isUp`, `isStopped`, `retrying`, `awaitsNextLink` and `refusal`. The lanes mix them. `message` checks `refusal() ?? isStopped()`. `receipt` skips both checks up front and only meets `refusal()` inside `budget()`. `link` skips everything. The loop exits on `!attempt.retry || !awaitsNextLink()`. The comment about it spinning helped, but I had to walk the phase transitions by hand to convince myself. The `Lane` doc comment resolved what each lane is for. It did not resolve what each lane actually checks.
|
||||||
|
3. **`src/messages/expiring-groups.ts:18`, `ExpiringGroups`, with `src/messages/reassembly.ts:110-136, 187-206`, `Reassembler.collect` / `trim`.** The store says outright that it enforces neither its `max` nor its timeout itself. The owner has to check `full`, call `takeExpired()`, and let `weigh()` evict, and `weigh()` can evict the very group being written. In `trim`, `answered = size - 1` for the current key, and the refused segment has already been `set` into the group. That is an invariant spread across two files. The doc comments state the contract honestly, so it was readable, just slow.
|
||||||
|
4. **`src/wire/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`.** Deciding which field is allowed to set `data_coding` goes through `CodingSource`. An empty Buffer `short_message` counts as source `message_payload`. A string body rewrites `data_coding`, but only when it encodes to something non-empty. Three branches return three different param shapes. The `CodingSource` doc comment is the key, and I only understood it after going back to `message-body.ts`.
|
||||||
|
5. **`src/messages/dlr-merger.ts:68-184`, `DlrMerger` (`open`, `spend`, `dropOldest`).** It keeps a second `ExpiringGroups<true>` as a tombstone set. `spend()` is called on completion, on expiry, on eviction and on id reuse, and each time it deletes and re-inserts the tombstone. The class doc comment explains why ("merged at most once"). I still had to trace which paths end up in `spend` to be sure a straggler receipt is ignored.
|
||||||
|
6. **`src/client.ts:219-322`, `bindOn` / `initialAttempts` / `keepTrying`.** For `fromStart` there are two `ReconnectLoop`s: one in the client and one inside each session. `lastErr` lives in a closure, and `settle` is idempotent. `bindOn` removes its abort listener on failure but deliberately keeps it after success, so a later abort closes a bound session. Only README ("That signal also closes the session once bound") told me that was intended rather than a leak.
|
||||||
|
7. **`src/defs/encodings.ts:483-514`, `messageClassEncoding` / `encodingByDataCoding`.** Bit masks over coding groups I had no background in. There is an orphan `//` comment sitting above a `/** */` doc comment. The GSM 03.38 codec is named `ascii`, and "fall back to ASCII" (line 504) actually means GSM7. That misled me until I checked the `encodings` map.
|
||||||
|
8. **`src/session/incoming-requests.ts:103-153`, `handle` / `route`.** `arrivedOn` is captured before the application's `onRequest` await and compared afterwards. The `unbind` case closes the session with `AbortSignal.abort()` from inside a handler that the dispatch itself is running. Both have comments, and both still needed a second read to be sure nothing re-enters.
|
||||||
|
|
||||||
|
2. **Least want to modify:** the `Session` lifecycle cluster (`session.ts:303-404` plus `LinkLife`). A change to when the link counts as up, stopped or ended touches `LinkLife`, `ReconnectLoop`, `OutgoingRequests.canCarry`, `client.ts` `bindOn`/`bind`, and `IncomingRequests`' `sock` check. Nothing in the types enforces the ordering, so it only lives in the comments.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:** `PduFramer`; the wire types in `defs/types.ts` (long but uniform); TLV read and write, including repeatable tags and keying by id; the reconnect backoff; `bind-direction.ts`; the `Result` convention; `udh.ts` `concatInfo`; `send-sms.ts` validation, which is a flat checklist; `PendingRequests`; `SendWindow`.
|
||||||
|
|
||||||
|
4. **Prose debt.**
|
||||||
|
- **Documentation I needed:**
|
||||||
|
- README "Reconnect" section, to know the abort listener kept alive in `bindOn` is intended.
|
||||||
|
- AGENTS "GSM 7-bit is sent unpacked", to trust `segmentUnits` GSM7 153 vs UCS2 134. The one-line comment at `message.ts:13` is close to enough on its own.
|
||||||
|
- README "Shutdown", to learn that `drain()` returning `{}` when `!canCarry()` also skips waiting on handlers. The code says "nothing is on the wire", which does not mention handlers.
|
||||||
|
- The charter's decision index points at `docs/decisions.md`, which I was not allowed to open. For several decisions I had only the title and had to take the rest on trust.
|
||||||
|
- **Where the charter's map is wrong:**
|
||||||
|
- It says `messages` uses `wire`. In fact `wire/pdu.ts` imports `messages/message.ts` (`decodeMessage`, `encodeBody`).
|
||||||
|
- `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`.
|
||||||
|
- `ConcatReference`, a per-session counter, lives in `udh.ts`.
|
||||||
|
|
||||||
|
Each of these cost a wrong turn.
|
||||||
|
- **Prose that told me nothing new:**
|
||||||
|
- `send()`'s "Sends a request and resolves with the peer's response".
|
||||||
|
- `LinkTimers`' "Keeps a quiet connection honest".
|
||||||
|
- `PduFramer`'s "Cuts a byte stream into whole PDUs".
|
||||||
|
- The charter's test conventions, which are irrelevant to reading `src/`.
|
||||||
|
- Most of the decision-index lines, which restate README behaviour.
|
||||||
|
- The 0.4.0 defect table. It is useful history but did not help me read the current code.
|
||||||
|
- **Duplicated code:** `collectSent` and `collectReceipt` are near-copies, `quoted()` exists twice, and the `emit` / `captureRejectionSymbol` guards are copied between `Session` and `SmppServer`.
|
||||||
|
|
||||||
|
5. **Scores.** The problem is intrinsically hard (SMPP session state, reassembly under memory caps, receipt correlation), and that gets no bonus.
|
||||||
|
- **Navigation 7:** near the "predictable" anchor. The `session/`, `messages/`, `wire/`, `defs/` split and the charter's file map took me from symptom to file first try. It is held below 8 by the misplaced units above and the false import-direction claim.
|
||||||
|
- **Locality 6:** between the 5 and 7 anchors. Link lifecycle state is split across `LinkLife`, `ReconnectLoop` and `Session`, with ordering rules marked only by comments. `IncomingRequests` reaches back into `session.sock`, `boundAs` and `linkEnd`.
|
||||||
|
- **Shape 7:** at "predictable". Fan-out per level is small and most names are honest. It is held there by `ascii` for the GSM codec, "fall back to ASCII", `udh.ts` also holding the reference counter, and six overlapping liveness predicates on `LinkLife`.
|
||||||
|
- **Self-sufficiency 7:** at "predictable". Comments cite SMPP sections and state invariants beside the code. Two behaviours (the kept abort listener, the drain skipping handlers) needed README open beside the code.
|
||||||
|
- **Overall 6:** a cold senior would be productive within a week and would know to fear the lifecycle cluster. The locality cost there is what holds it below 7.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=7 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft C, architect seat
|
||||||
|
|
||||||
|
**Comprehension panel report: Architect, inherited (draft-c, @larvit/smpp)**
|
||||||
|
|
||||||
|
I opened no test file. I read every non-test source file under `src/`. I read `defs/types.ts` and `defs/tlvs.ts` by outline plus key sections, and `commands.ts`, `constants.ts` and `errors.ts` only as far as their outline.
|
||||||
|
|
||||||
|
## 1. Map from README and file tree only, verbatim
|
||||||
|
|
||||||
|
- **Root, the entry points:** `client.ts` has `client()`; `server.ts` has `server()` and `SmppServer`; `session.ts` has `Session`, the orchestrator; `index.ts` is the public surface.
|
||||||
|
- **Root, cross-cutting:** `result.ts` (Result), `log.ts` (SmppLog), `error-from.ts` (unknown → Error). `defaults.ts` I expect to hold session defaults, and I am unsure how it relates to `session/session-options.ts`.
|
||||||
|
- **`defs/`:** the SMPP spec tables: commands, TLVs, errors, constants, encodings, wire types.
|
||||||
|
- **`wire/`:** the codec. `pdu.ts` is pduToObj/objToPdu, `pdu-framer.ts` turns a byte stream into PDUs, `pdu-refusal.ts` is PduRefusedError.
|
||||||
|
- **`session/`:** what a Session is made of: transport, keepalive timers, reconnect, send window, pending-request correlation, incoming and outgoing requests, bind direction, options. `running-handlers.ts` I guess counts `onSms` promises (README: "1000 handlers", "close() waits for your handlers"). `idle-waiters.ts` and `link-life.ts` are unclear from their names.
|
||||||
|
- **`messages/`:** message-level logic: encode/split, UDH, concatenation, DLR parse and merge, reassembly, the inbound `Sms` handle, sendSms composition, ids, uuid.
|
||||||
|
- **Names that do not give their purpose:** `retained-pdu.ts`, `expiring-groups.ts`, `unanswered-error.ts` (why in messages/?), `uuid.ts` (why in messages/?), `pdu-transport.ts` (session, not wire?), `link-life.ts`.
|
||||||
|
- **README promises I expect to find:** a drain that waits for handlers, then for requests. `smppTime` somewhere in messages. No store (goal 9 says it has not shipped).
|
||||||
|
|
||||||
|
## 2. Where the map was wrong, and what each correction cost
|
||||||
|
|
||||||
|
| Map claim | Reality | Cost |
|
||||||
|
|---|---|---|
|
||||||
|
| `wire/` is the codec | The per-field codec is `defs/types.ts` (684 lines of read/size/write) and `defs/tlvs.ts` (`parseTlvs`/`writeTlvs`). `wire/pdu.ts:10` imports `encodeBody` and `decodeMessage` from `messages/message.ts`, so wire depends on messages. AGENTS.md says the reverse ("`messages` uses `wire`"). | High. I had to reopen `defs/`, and the documented dependency direction is false. |
|
||||||
|
| `defaults.ts` holds session defaults | It holds every option's default plus `bounds` (limits that are not options). | Low. |
|
||||||
|
| `session-options.ts` holds option types | It also holds `SessionEvents`, `OnRequest`, `SmsHandler`, and the validation for client and server options (`CheckableOptions` includes `authenticate`, `connectTimeout`, `fromStart`). | Medium. |
|
||||||
|
| One reconnect concept | `client.ts:278` `keepTrying` runs a second, separate `ReconnectLoop` for `fromStart`. `ReconnectOptions` (session: `connect`/`onConnected`) and the client's `reconnect` (`ReconnectTuning`) are two shapes under one name. | Medium. |
|
||||||
|
| `running-handlers.ts` counts `onSms` promises | Correct. | None. |
|
||||||
|
| `messages/` is message logic | It is a 14-file grab-bag with 5 themes: codec helpers, receipts, bounded stores, sending, ids/errors. | Medium. |
|
||||||
|
|
||||||
|
## 3. Fan-out, level by level
|
||||||
|
|
||||||
|
- **L0, `src/`:** 8 files and 4 directories. It mixes 4 entry points with 4 utilities. Acceptable.
|
||||||
|
- **L1:**
|
||||||
|
- `session/`: 12 files. `Session` composes 7 collaborators plus `ConcatReference`.
|
||||||
|
- `messages/`: 14 files across about 5 themes.
|
||||||
|
- `wire/`: 3 files.
|
||||||
|
- `defs/`: 7 files.
|
||||||
|
- **L2, inside `session.ts`:** about 25 members. The lifecycle cluster alone (`drain`/`stop`/`dropSocket`/`linkLost`/`end`) touches 6 collaborators.
|
||||||
|
- **Worst level:**
|
||||||
|
- By count and cohesion, `messages/` (14 files).
|
||||||
|
- By reading cost, `session/`. `link-life.ts` has 7 near-synonymous predicates, `OutgoingRequests.canCarry` is an 8th, and `ReconnectLoop.isStopped` a 9th.
|
||||||
|
|
||||||
|
## 4. Names
|
||||||
|
|
||||||
|
**Names that mislead**
|
||||||
|
- `encodings.ts:45` `ascii` is the GSM 03.38 codec. The comment at `encodings.ts:180` says "alphabets with no codec fall back to ASCII", but the code returns `'GSM7'`.
|
||||||
|
- `ExpiringGroups` enforces neither the cap nor the expiry; its own doc comment says so at `expiring-groups.ts:18`.
|
||||||
|
- `defs/` is described as "spec tables" but holds most of the codec.
|
||||||
|
- `udh.ts` holds the outbound `ConcatReference` counter next to UDH parsing.
|
||||||
|
- `messages/unanswered-error.ts` is used by `session/outgoing-requests.ts`.
|
||||||
|
|
||||||
|
**Concepts with two names**
|
||||||
|
- `sendSms` and `submitSms` name the same action.
|
||||||
|
- GSM7 and `ascii` name the same codec.
|
||||||
|
- "stopped" is held twice: `LinkLife.stopped` and `ReconnectLoop.halted`, both set by `Session.stop()`.
|
||||||
|
- `smsId`, `message_id` and `base` refer to the same id.
|
||||||
|
|
||||||
|
**One name over several concepts**
|
||||||
|
- `idle`: the `LinkTimers` idle timeout, `IdleWaiters` (a count falling to zero), and `ExpiringGroups.idle()` (stop the sweeper).
|
||||||
|
- `release`: `SendWindow.release` frees a slot, `RunningHandlers.release` wakes the drain, `LinkLife.release` settles link waiters.
|
||||||
|
- `settle`: used everywhere.
|
||||||
|
- `reconnect`: the session's `ReconnectOptions` and the client's `ReconnectTuning`. `checkReconnect` validates only the client shape.
|
||||||
|
|
||||||
|
## 5. What I would restructure, ranked
|
||||||
|
|
||||||
|
1. **Move the codec into `wire/`:** `defs/types.ts` read/write, `defs/tlvs.ts` parse/write, and `encodeBody`/`decodeMessage`. This makes the documented dependency direction true.
|
||||||
|
2. **Split `messages/`** into inbound, outbound and receipts. Move `unanswered-error` to `session/`, and move `uuid` out.
|
||||||
|
3. **Collapse the link predicates** in `LinkLife` into one query per lane (for example `admits(lane)`), absorbing `canCarry` and `ReconnectLoop.halted`.
|
||||||
|
4. **Split `session-options.ts`:** event and hook types in one place, client/server option checking in another.
|
||||||
|
5. **Renames:** `ascii` → `gsm7`, `ExpiringGroups` → something that says it only holds keyed deadlines, and distinct names for the `idle`, `release` and `settle` overloads.
|
||||||
|
|
||||||
|
**What the structure gets right**
|
||||||
|
- `Session` is split into collaborators, each with a one-line owner doc.
|
||||||
|
- `Result` is used uniformly.
|
||||||
|
- Comments record why at the call site (for example `link-life.ts:173`, `reassembly.ts:162`, `dlr-merger.ts:23`).
|
||||||
|
- The AGENTS.md file map is accurate at file level.
|
||||||
|
- Every store is bounded and says so.
|
||||||
|
|
||||||
|
## 6. The 3am question
|
||||||
|
|
||||||
|
**Time and route, cold:** about 5–10 minutes. README "Shutdown" → `session.ts:267` `close()` → `session.ts:336` `drain()` → `incoming.idle` → `session/running-handlers.ts:54` `RunningHandlers.run` and `:89` `idle`.
|
||||||
|
|
||||||
|
**The premise does not match this code.** `sms.sendResp()` does not exist here; a grep for it finds nothing. Every message is answered on arrival (`incoming-requests.ts:233`), and the drain waits for the promise the `onSms` handler returned to settle, not for any answer.
|
||||||
|
|
||||||
|
**Likely cause:** a handler whose promise has not settled. The typical case is a handler awaiting `sms.sendDlr()` while the peer never answers the `deliver_sm`: `responseTimeout` (30 s) is longer than `shutdownTimeout` (5 s). The second place to look is the phase after it: `outgoing.idle` → `SendWindow.unfinished()` (`send-window.ts:82`), which counts queued waiters as well as requests on the wire.
|
||||||
|
|
||||||
|
**Adjacent hazard (plausible, not confirmed):** `session.ts:340` returns before waiting for handlers when the link cannot carry requests. A `close()` during a reconnect gap therefore skips the handler wait, which README step 2 says always happens. A slow `onRequest` hook is never counted by the drain either.
|
||||||
|
|
||||||
|
**Where it rots first:** the lifecycle cluster in `session.ts:336-404`. Its correctness depends on call order: `linkLost` reads `retrying()` before `dropSocket`, and `end` calls `stop` and `dropSocket` before the phase check. Every new link state adds a predicate to `LinkLife`.
|
||||||
|
|
||||||
|
**Where the next two features land:**
|
||||||
|
- Goal 9's store cuts across `DlrMerger`, `Reassembler` and `ExpiringGroups` in `messages/`, and `RunningHandlers` in `session/`. It has no single seam today.
|
||||||
|
- A per-PDU rate limit (goal 7) becomes a fourth wait in the `OutgoingRequests.carry` loop (`outgoing-requests.ts:100`).
|
||||||
|
|
||||||
|
## 7. Hardest places, ranked
|
||||||
|
|
||||||
|
1. `src/session.ts:336-404`, `drain`/`stop`/`dropSocket`/`linkLost`/`end`: order dependence, and the early return that skips handlers.
|
||||||
|
2. `src/session/link-life.ts:50-82`, the `LinkLife` predicates: phase × stopped × reconnects expressed as 7 booleans.
|
||||||
|
3. `src/session/outgoing-requests.ts:83-121`, `request`/`carry`: 3 lanes and a retry loop whose own comment warns that it spins.
|
||||||
|
4. `src/session/incoming-requests.ts:103-128`, `IncomingRequests.handle`: holds a Session back-reference, awaits `onRequest`, then re-checks the socket. The session is reachable by two routes: the object and the `sendReceipt` closure.
|
||||||
|
5. `src/messages/reassembly.ts:110-206`, `Reassembler.collect`/`trim`: eviction by weight, with the `parts - 1` accounting for a segment that was refused.
|
||||||
|
6. `src/messages/dlr-merger.ts:150-173`, `DlrMerger.open`/`spend`: a second `ExpiringGroups` used as a set of spent ids.
|
||||||
|
7. `src/wire/pdu.ts:84-136`, `resolveShortMessage`/`resolveBody`: which source's `data_coding` wins.
|
||||||
|
8. `src/client.ts:250-322`, `keepTrying`/`initialAttempts`: a second reconnect mechanism.
|
||||||
|
|
||||||
|
**The unit I would least want to modify:** the `Session` lifecycle cluster, `session.ts:366-404` (`dropSocket`/`linkLost`/`end`).
|
||||||
|
|
||||||
|
## 8. Scores
|
||||||
|
|
||||||
|
The problem is intrinsically hard: SMPP session lifecycle with reconnect, drain, send window and bounded reassembly. That earns no bonus.
|
||||||
|
|
||||||
|
- **Navigation 7.** Sits at "Predictable": the AGENTS.md map and file names took me from symptom to `RunningHandlers` in minutes. It is held below 8 because the codec is split between `defs/` and `wire/`, and `messages/` is a grab-bag.
|
||||||
|
- **Locality 6.** Between "Honest middle" and "Predictable". The collaborators are real. It is held there by `IncomingRequests` holding a `Session` back-reference, by stopped/up state spread across `LinkLife`, `ReconnectLoop` and `transport.sock.destroyed`, and by the order-dependent `end`/`linkLost`.
|
||||||
|
- **Shape 6.** Between the anchors. It is held there by the 14-file `messages/`, a `LinkLife` API of 9 predicates, the wire→messages import that contradicts AGENTS.md, and names that lie (`ascii`, `ExpiringGroups`, `idle`/`release` overloads).
|
||||||
|
- **Self-sufficiency 7.** Sits at "Predictable": the invariants and whys are stated beside the code. It is held below 8 because the drain's early return at `session.ts:340` contradicts the README's shutdown contract, and the file map's layering claim is false.
|
||||||
|
- **Overall 6.** Capped at 7 by the lowest dimension plus one. It sits at 6 because the hardest code (the lifecycle and the link predicates) is exactly where the order dependence lives.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft D, junior seat
|
||||||
|
|
||||||
|
1. **Hardest places, hardest first**
|
||||||
|
|
||||||
|
1. **`src/handled-messages.ts:92` `HandledMessages.offer`, `:125` `run`, and `src/sms.ts:83` `createSms`.**
|
||||||
|
- `offer` creates an object whose own closure sets `handled.answered`. `sendResp` then takes one of two paths depending on `answeredAs` (`sms.ts:97`).
|
||||||
|
- After a handler fails, `run` calls `sms.sendResp({ status: retryStatus })` (`:133`). For a multipart message, `answeredOnArrival()` returns an `err` there and nobody reads it.
|
||||||
|
- I had to trace three files to see that this is intended: the segments were already answered, so there is nothing left to refuse. No comment at `:133` says so.
|
||||||
|
- Still opaque: what "settle" means here, compared with `IdleWaiters.settle`.
|
||||||
|
|
||||||
|
2. **`src/session.ts:239-323`, the shutdown verbs: `unbind`, `close`, `drain`, `finish`, `linkLost`, `dropLink`.**
|
||||||
|
- There are six near-synonyms, plus `LinkLife.stop`/`drop`/`end` underneath them. `stop()` gets called twice (in `drain` and again in `finish`).
|
||||||
|
- `unbind`'s return line, `sent.err && !closedOnUnbind ? … : drained`, needed a truth table.
|
||||||
|
- Re-entrancy: an inbound `unbind` (`incoming-requests.ts:149`) calls `session.close()`, which calls `incoming.drain()` on the same object that is still mid-`route`.
|
||||||
|
- Doc comments helped with each piece. The overall state machine was never written down in one place.
|
||||||
|
|
||||||
|
3. **`src/outgoing-requests.ts:120` `refusal()` and `:74` `request()`, with `src/link-life.ts:31` `LinkLife`.**
|
||||||
|
- `LinkLife` exposes seven predicates (`isAttached`, `isUp`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`).
|
||||||
|
- Callers mix them with `canCarry()` (which also checks `sock.destroyed`). `refusal()` at `:129` reads `isStopped() && canCarry()`, and its comment ("the link's own refusal names the session closed instead") only made sense once I had held all four phases in my head.
|
||||||
|
- Also surprising: the phase starts at `'up'` before any bind. I had to hunt through `comeBackUp` to see that `'binding'` exists only on reconnect.
|
||||||
|
|
||||||
|
4. **`src/reassembly.ts:186` `Reassembler.trim`, with `src/expiring-groups.ts:70` `ExpiringGroups.weigh`.**
|
||||||
|
- `ExpiringGroups` is a store whose rules its owners enforce: `set()` never evicts, `weigh()` does, `full` is only advisory, and `onSweep` must call `takeExpired()`.
|
||||||
|
- `trim` reweighs the whole group, may evict the group it is working on, and then computes `answered = parts.size - 1` for that one.
|
||||||
|
- The `:199` comment explains the `-1`. It took several rereads to see why `open()` evicts by count and `trim` by weight.
|
||||||
|
|
||||||
|
5. **`src/pdu.ts:84` `resolveShortMessage` and `:113` `resolveBody`.**
|
||||||
|
- `CodingSource` decides whether `short_message` or `message_payload` gets to set `data_coding`. An empty-string `short_message` flips it to `'message_payload'`.
|
||||||
|
- I had to hold four cases at once: Buffer vs string, empty vs not, plus the TLV text. The type comment at `:74` is accurate but dense.
|
||||||
|
- I had no domain context for why a body could live in two places. The README's "Where the body is" resolved that.
|
||||||
|
|
||||||
|
6. **`src/client.ts:253` `initialAttempts`, `:276` `keepTrying`, `:218` `bindOn`.**
|
||||||
|
- This is a second use of `ReconnectLoop`, separate from the one `Session` owns. It builds a fresh `Session` per attempt, and a `lastErr` closure is shared across attempts.
|
||||||
|
- `bindOn` comes with an ordering warning: "close() must reach the loop's stop() before its first await". Checking that required reading `Session.close` → `drain` → `reconnectLoop.stop()`.
|
||||||
|
- It resolved once I saw that `fromStart` is the only path into this code.
|
||||||
|
|
||||||
|
7. **`src/dlr-merger.ts:150` `open`, `:166` `spend`.**
|
||||||
|
- There are two `ExpiringGroups`, and the second one (`spent`) is a tombstone set. `spend` deletes from both, evicts the oldest tombstone, then re-adds.
|
||||||
|
- The class doc (`:62-67`) explains the "merged once" rule. Without it this would have stayed opaque.
|
||||||
|
|
||||||
|
8. **`src/defs/encodings.ts:162` `messageClassEncoding`, `:182` `encodingByDataCoding`, `:45` `ascii`.**
|
||||||
|
- Bitmask rules come from a spec I have not read. The codec named `ascii` is actually the GSM 03.38 table (`GSM: ascii`), which misled me at first.
|
||||||
|
- I took the comments on trust; I could not check them.
|
||||||
|
|
||||||
|
I opened no tests.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify:** `HandledMessages` together with `createSms` (`handled-messages.ts:92-140`, `sms.ts:83-157`). The "answered" state lives in three places: `answer.done` in the closure, `handled.answered`, and `answeredOnArrival`. They are updated by callbacks across two files. Whether the peer gets exactly one response depends on all three agreeing, and a mistake silently double-answers or never answers a request.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:**
|
||||||
|
- The wire codec. `defs/types.ts` is long but repetitive, and every read and write checks its range the same way.
|
||||||
|
- `PduFramer` and `PduTransport`.
|
||||||
|
- `send-sms.ts`: `checkOptions` is a flat, ordered pipeline.
|
||||||
|
- `sms-id.ts`, `concat.ts`, `udh.ts`: small files whose names tell the truth.
|
||||||
|
- The "nothing throws" rule makes every call site look the same, so I stopped needing to think about control flow.
|
||||||
|
|
||||||
|
4. **Prose debt.**
|
||||||
|
- **Needed:**
|
||||||
|
- I needed domain background: what a DLR is, `esm_class`, `data_coding`, UDH vs `sar_*`, and why `data_sm` changes meaning with direction. None of it is in `src/`.
|
||||||
|
- I found it in README.md sections "Receiving in depth", "Server in depth" and "Delivery receipts". That cost reading about 780 lines to extract about 60 useful ones.
|
||||||
|
- Comments cite SMPP section numbers (e.g. "5.3.2.26", "4.6.2") that a junior cannot resolve without the spec.
|
||||||
|
- The AGENTS.md architecture list was the most valuable single piece: one line per file, and accurate.
|
||||||
|
- **Told me nothing:**
|
||||||
|
- Comments that restate the code: `Session.send` "Sends a request and resolves with the peer's response.", `client()` "Connects to an SMSC and binds.", `LinkTimers.clear` context, and `bindCarries`'s doc, which mostly repeats its three lines.
|
||||||
|
- The long AGENTS.md "Conventions" paragraph on test fixtures (irrelevant to reading `src/`).
|
||||||
|
- The defects table: it is history, not an explanation of the current code, though it did hint at domain pitfalls.
|
||||||
|
|
||||||
|
5. **Scores** (the problem's own difficulty is high: a stateful protocol with reconnect, drain and reassembly, and it gets no bonus here):
|
||||||
|
- **Navigation 7.** Predictable: the AGENTS.md file map plus descriptive file names got me to the right file first try for almost every question. What holds it below 8: one symptom such as "why was this refused with ESME_RTHROTTLED" is spread across `incoming-requests.ts` (`retryStatus`, `refusedSegmentStatus`), `handled-messages.ts` (`refuses`) and `reassembly.ts` (`Refusal`).
|
||||||
|
- **Locality 5.** Honest middle: liveness state in `LinkLife` is read through seven predicates from three classes. `generation()` is captured in closures (`incoming-requests.ts:106`, `:253`), `answered` is mutated through a callback, and ordering constraints are documented only in comments (`client.ts:239`, `session.ts:349`).
|
||||||
|
- **Shape 6.** Between 5 and 7: classes are small and fan-out is bounded, but some names mislead. The GSM codec is called `ascii`, and six-plus near-synonymous teardown verbs (`stop`/`end`/`drop`/`finish`/`linkLost`/`dropLink`/`clear`) mark distinctions I had to work out myself.
|
||||||
|
- **Self-sufficiency 5.** Honest middle: the code comments give terse, accurate reasons, but the domain model a newcomer needs to read them lives only in README.md and the SMPP spec, so I kept the README open the whole time.
|
||||||
|
- **Overall 5.** Capped at 6 by locality; I land at 5 because both locality and self-sufficiency cost me rereads on the stateful session core. The codec and message layers alone would sit near 7.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=5 shape=6 self=5 overall=5
|
||||||
|
|
||||||
|
## Draft D, mid seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked hardest first**
|
||||||
|
|
||||||
|
1. **`src/session.ts:265-362`: `Session.drain` / `finish` / `linkLost` / `dropLink` / `comeBackUp`, read together with `src/link-life.ts:31-135` (`LinkLife`).**
|
||||||
|
- One question, "can a request go out right now?", depends on four pieces of state: `LinkLife.phase` (binding/down/ended/up), `LinkLife.stopped`, `ReconnectLoop.halted`, and `OutgoingRequests.canCarry()`. The last one is `link.isUp() && !sock.destroyed`.
|
||||||
|
- `drain()` calls `link.stop()`, then branches on `canCarry()`. `finish()` calls `stop()` again, then `dropLink()`, then `end()`.
|
||||||
|
- `comeBackUp` uses `!this.link.retrying()` to mean "close() landed during the rebind". Here `retrying()` is being used as a stand-in for "not stopped", which the name hides.
|
||||||
|
- I had to trace every caller by hand to be sure `close` fires exactly once and `disconnected` is never followed by `close` on the same drop. The per-method doc comments helped. Nothing ties the whole state machine together in one place; this stayed the most expensive read.
|
||||||
|
2. **`src/outgoing-requests.ts:262-351`: `OutgoingRequests.request` / `refusal` / `attempt`.**
|
||||||
|
- A `for(;;)` loop holds one link-wait budget (`link.hold()` returns a closure) plus a window slot, and retries only when `retryOnNextLink && awaitsNextLink()`.
|
||||||
|
- `refusal` line 317 (`pastDrain !== true && isStopped() && canCarry()`) is a three-way condition. Its comment explains why the *other* branch exists, not this one.
|
||||||
|
- `attempt` registers `pending.wait` before `transport.write`, and checks abort twice (in `refusal` and again in `attempt`). The comment at line 325 resolved the second check.
|
||||||
|
- The `pastDrain` flag reaches here from `IncomingRequests` through `Session.incomingFor`. It is action at a distance.
|
||||||
|
3. **`src/pdu.ts:84-136`: `resolveShortMessage` / `resolveBody` (plus `readParams` at 249).**
|
||||||
|
- The `CodingSource` return value decides whether an encoded `message_payload` may overwrite `data_coding`. Without the domain, I had to derive why an empty `short_message` hands `data_coding` to the TLV. The `CodingSource` doc comment half-resolves it.
|
||||||
|
- `readParams` passes `paramNumber(params.sm_length, 0)` as the length to *every* field's `read`. It only works because `sm_length` precedes `short_message` in wire order. That is an order dependence stated only as the general "parameter order is wire order" warning, not at the call.
|
||||||
|
4. **`src/handled-messages.ts:359-423` and `src/expiring-groups.ts:442-554`: `HandledMessages.offer` / `run` / `refuses`, and `ExpiringGroups`.**
|
||||||
|
- `ExpiringGroups`'s contract is inverted: it "enforces neither max nor timeout itself", only `weigh()` evicts, `onSweep` must call `takeExpired()`, and `takeOldest` goes through `delete` (which stops the timer) while `takeExpired` goes through `remove`. Each owner (Reassembler, DlrMerger, HandledMessages) re-implements the policy.
|
||||||
|
- In `HandledMessages`, the `answered` flag is set by a closure threaded into `createSms`. `run` uses an identity check (`running.get(key) !== handled`) to detect that `clear`/`sweep` got there first. `refuses()` has hysteresis state (`atBound`) and calls `sweep()` as a side effect.
|
||||||
|
- The class doc comment resolved the intent. The mechanics took rereads.
|
||||||
|
5. **`src/incoming-requests.ts:105-260` together with `src/server.ts:542-567`: `IncomingRequests.handle` / `route` / `onMessage` / `offer`, and `handleRequest`.**
|
||||||
|
- Searching for where a bind is accepted, I found `IncomingRequests.unhandled` answering binds with `ESME_RALYBND`. The real bind handling is in server.ts, injected as `onRequest`, so the "application hook" slot is also the server's own bind handler.
|
||||||
|
- The charter's Decisions index says this ("composes the application's onRequest after its own bind handling"), but the reader gets there only after a wrong turn.
|
||||||
|
- Link generation is checked twice by different mechanisms: inline in `handle`, and as a `lostLink` closure in `offer`.
|
||||||
|
- `carriedAs`/`standsInFor` rewrites `data_sm` depending on `linkEnd`, a mutable public field set after construction (`session.linkEnd = 'smsc'` in server.ts:586).
|
||||||
|
6. **`src/reassembly.ts:425-444`: `Reassembler.trim`.**
|
||||||
|
- `weigh()` returns evicted groups, possibly including the current one. `answered = parts.size - 1` excludes the refused segment from the loss count.
|
||||||
|
- `collect` only weighs incomplete groups; the completing segment is never weighed. I had to confirm that is intended.
|
||||||
|
- The comments at 361 and 437 resolved it, after two reads.
|
||||||
|
7. **`src/dlr.ts:342-424`: `messageType` / `receiptStatus` / `dlrFromPdu`.**
|
||||||
|
- Four message types, with `'unmarked'` meaning "maybe a receipt if the body parses to both an id and a state". Status comes from TLV, then body, then UNKNOWN, and `statusId` and `statusMsg` can disagree by design.
|
||||||
|
- This is domain-heavy but well commented with spec sections. README's "Delivery receipts" section closed the gap.
|
||||||
|
8. **`src/defs/encodings.ts:302-341`: `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.**
|
||||||
|
- Bit-twiddling over GSM 03.38 coding groups that I had no context for. The comments give the bit positions, so it resolves with care.
|
||||||
|
- The GSM codec object is named `ascii` (line 196), which is a lying name for GSM 03.38.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify: the Session link lifecycle (`Session.drain` / `finish` / `linkLost` / `comeBackUp`) together with `LinkLife`.**
|
||||||
|
- Correctness depends on call order across three classes. `stop()` must reach the loop before the first await; client.ts:239 says so from *another file*.
|
||||||
|
- `close` and `disconnected` must stay exclusive, and `end()` must release waiters exactly once.
|
||||||
|
- Any change risks a hung `close()` or a double `close` event, and nothing local tells you which invariant you just broke.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy.**
|
||||||
|
- The wire codec in `defs/types.ts`: repetitive, every read is range-checked, and `Result` is uniform.
|
||||||
|
- `PduFramer`: short, with the quadratic concern stated.
|
||||||
|
- The `send-sms.ts` check pipeline: linear and flat, each refusal named.
|
||||||
|
- `DlrMerger` severity ranking: one comment explains why wire values can't be compared.
|
||||||
|
- `bind-direction.ts`: `standsInFor`/`bindCarries` are tiny and explained.
|
||||||
|
- The UDH walk in `udh.ts`.
|
||||||
|
- The no-throw discipline made every call site read the same way.
|
||||||
|
|
||||||
|
4. **Prose debt.**
|
||||||
|
- **Needed, and where I found it:**
|
||||||
|
- What ESME and SMSC are. Only inferable from `LinkEnd`'s comment and README.
|
||||||
|
- What "answered on arrival" means. README "Server in depth", roughly a 400-line scroll.
|
||||||
|
- Why `data_sm` flips direction. The comment in bind-direction.ts is sufficient.
|
||||||
|
- How the server's bind handling composes with `onRequest`. Only in the AGENTS.md Decisions index, as one line whose reasoning is in docs/decisions.md, which I was barred from.
|
||||||
|
- The 134/153 segment budget. Covered both inline (message.ts:364) and in AGENTS, so it was cheap.
|
||||||
|
- Many AGENTS decision lines are pointers into a file I couldn't open. For the lifecycle ("A deliberate shutdown drains; an unusable link and an abort do not"), the one-liner was the only statement of the rule the code implements.
|
||||||
|
- The AGENTS architecture map was accurate and was the cheapest, most useful prose.
|
||||||
|
- **Told me nothing the code didn't already say:**
|
||||||
|
- `Session.send`'s "Sends a request and resolves with the peer's response."
|
||||||
|
- README's "Everything exported" table, which duplicates `index.ts`.
|
||||||
|
- The `defaults.ts` preamble.
|
||||||
|
- AGENTS "Conventions", about 30 lines on test fixtures and teardown, which are irrelevant to reading `src/`.
|
||||||
|
- The repeated "Injected so expiry can be exercised without a wall clock" on four options types.
|
||||||
|
- **Minor drift:** README types `onSms` as `(sms) => Promise<void> | void`; the code declares `(sms) => unknown`.
|
||||||
|
|
||||||
|
5. **Scores.** Intrinsic difficulty is high: a stateful protocol session with reconnect, drain, windowing and reassembly. It gets no bonus.
|
||||||
|
- **Navigation 8.** Above 7 "the layout answers where does this live": `src/` is flat, file names match contents, and the AGENTS map is accurate. It stops short of 9 because server bind handling lives behind the `onRequest` slot, which cost one wrong turn.
|
||||||
|
- **Locality 6.** Between 5 and 7: most units stand alone. Link liveness is split across `LinkLife.phase`, `stopped`, `ReconnectLoop.halted`, `canCarry()`'s socket check and generation counters. Changing shutdown means holding session.ts, link-life.ts, outgoing-requests.ts and client.ts at once, and `linkEnd` is mutated after construction.
|
||||||
|
- **Shape 7.** At "predictable": classes are small and fan-out is bounded per level. A few names lie: `ascii` for the GSM codec, `HandledMessages` for messages still being handled, `retrying()` used as "not closed", and `settle` meaning different things in five classes.
|
||||||
|
- **Self-sufficiency 7.** At 7: comments carry the why with spec section references at the hard points (receipts, UDH, data_coding bits). What is missing is the lifecycle invariant and the bind composition, which exist only as index lines pointing at a decisions file.
|
||||||
|
- **Overall 6.** Capped at loc+1 = 7. I place it at 6 because the hardest part, the session lifecycle, is hard both because the problem is hard and because its state is spread across files. It is neither localized nor marked as one place.
|
||||||
|
|
||||||
|
SCORES nav=8 loc=6 shape=7 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft D, senior seat
|
||||||
|
|
||||||
|
1. **Hardest places, hardest first**
|
||||||
|
|
||||||
|
1. **The link lifecycle across four owners.** `src/session.ts:265-362` (`drain`, `finish`, `linkLost`, `dropLink`, `comeBackUp`), `src/link-life.ts:31` (`LinkLife`), `src/reconnect-loop.ts` (`stop`/`halted`) and `src/outgoing-requests.ts:120` (`refusal`, which uses `canCarry()` = `link.isUp() && !sock.destroyed`).
|
||||||
|
- There are three "stopped" notions: `LinkLife.stopped`, `ReconnectLoop.halted`, and `phase === 'ended'`, which also sets `stopped`. `drain()` and `finish()` each call `link.stop()` and `reconnectLoop?.stop()`, so I had to hold the order of the calls to see which one decides the `close` event versus `disconnected`.
|
||||||
|
- `link-life.ts:38` starts `phase` at `'up'`, although `'binding'` exists. The first link therefore reports `isUp()` before any bind, and the charter's "a bind is what makes it one" holds only for reconnected links. I worked this out myself; nothing in the code says so. The code mostly explains itself, but that one point stayed opaque.
|
||||||
|
2. **Who answers a failed handler.** `src/handled-messages.ts:92-140` (`offer`, `run`), together with `src/sms.ts:83-157` (`createSms`, `sendResp`, `answeredOnArrival`) and `src/incoming-requests.ts:252` (`offer`).
|
||||||
|
- An `answered` flag is set through a callback that `offer` builds around a `handled` const, which that same closure refers to. `SmsRoute = Omit<SmsHandlers,'answered'>` and a mutable `Answer` object add further state, and the `lostLink` generation closure is built in yet another class.
|
||||||
|
- When a multipart handler fails, `run()` calls `sendResp({status: retryStatus})`. That returns an `err` from `answeredOnArrival`, and the `err` is discarded. That is how "its answer stands" comes out right, and nothing says so. README's "A handler that fails" bullet resolved it.
|
||||||
|
3. **Reassembly eviction.** `src/reassembly.ts:110` (`collect`) and `:187` (`trim`), with `src/expiring-groups.ts:70` (`weigh`).
|
||||||
|
- `weigh()` can evict the group that is being weighed, so `trim` counts it as `parts.size - 1` answered. The `full` / `unplaceable` refusals map to three statuses in `refusedSegmentStatus`.
|
||||||
|
- `ExpiringGroups` enforces its limits unevenly: only `weigh` evicts, while `max` and `timeout` fall to the owners, and I had to find that in its class docstring. The inline comments resolved it, but it took a reread.
|
||||||
|
4. **Building and reading the message body.** `src/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`) and `:249` (`readParams`).
|
||||||
|
- On the write side, `CodingSource` decides which of `short_message` and `message_payload` may overwrite `data_coding`. An empty buffer counts as `'message_payload'`. I had to hold four branches at once.
|
||||||
|
- On the read side, `readParams` passes `sm_length` as the `length` argument to every wire type's `read`. The same parameter means the TLV length in `defs/types.ts`. Only the `commands.ts` comment on wire order hints at this coupling.
|
||||||
|
5. **data_coding bit logic.** `src/defs/encodings.ts:159-190` (`messageClassEncoding`, `encodingByDataCoding`).
|
||||||
|
- It is dense bit-twiddling with no context, and a `//` comment sits above a separate `/** */` block, so I could not tell which of the two it belonged to.
|
||||||
|
- Some names are wrong. `'LATIN1'` is returned for 8-bit binary. The GSM codec is named `ascii` (`:45`).
|
||||||
|
- The docblocks resolved most of it. The class-group comment stayed only half clear.
|
||||||
|
6. **`DlrMerger`'s two stores.** `src/dlr-merger.ts:68`, with `spend` at `:166` and `open` at `:150`. Two `ExpiringGroups` (`groups` and `spent`) are both mutated by `spend()`. `open()` checks both, and `dropOldest` spends. The class docstring explained the "merged at most once" rule. I still had to trace the steps by hand.
|
||||||
|
7. **Retrying the first bind.** `src/client.ts:218-320` (`bindOn`, `initialAttempts`, `keepTrying`). This is a second `ReconnectLoop` outside `Session`, with a fresh session per attempt and a `lastErr` closure. Correctness depends on the order in which abort listeners are added and removed, and on the comment "close() must reach the loop's stop() before its first await". The comments resolved it.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify:** `LinkLife` (`src/link-life.ts`). `Session`, `OutgoingRequests` (`isUp`, `awaitsNextLink`, `refusal`, `hold`) and `IncomingRequests` (`generation`) all read its phase. Its `stopped` flag duplicates the reconnect loop's, and its initial `'up'` is an unstated exception to its own `binding` rule. A change there reaches the drain, the queued sends and response correlation, and no single file shows all of that.
|
||||||
|
|
||||||
|
3. **Expected to be hard, found easy:**
|
||||||
|
- The codec: `defs/types.ts` is long but uniform, and every read is range-checked the same way.
|
||||||
|
- `PduFramer`, `ReconnectLoop` and `PendingRequests`.
|
||||||
|
- The typing of `TlvInputs` and `Tlvs`.
|
||||||
|
- `splitMessage` and the budget per segment.
|
||||||
|
- Navigation overall: the charter's one-line-per-file map matched the tree exactly.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **Needed, and what it cost to find:**
|
||||||
|
- README "Receiving in depth" and "Shutdown", to learn the half-bound hysteresis, the five-minute handler cutoff, and what happens when a handler fails after answering. Finding them was cheap, but they sit in a user document, not beside `HandledMessages`.
|
||||||
|
- The rationale for the link-life decisions. AGENTS.md only indexes it ("One owner decides whether a link can carry a request…") and I was not allowed to open `docs/decisions.md`, so the initial-`'up'` question stayed open.
|
||||||
|
- I opened no tests.
|
||||||
|
- **Told me nothing the code did not already say:**
|
||||||
|
- `session.ts:203` "Sends a request and resolves with the peer's response".
|
||||||
|
- The getter docstrings on `boundAs` and `peerInterfaceVersion`.
|
||||||
|
- `UnansweredError`'s docstring, which restates its message.
|
||||||
|
- `retryStatus`'s docstring.
|
||||||
|
- The idle-timeout rationale, written twice (`defaults.ts:13` and `client.ts:193`).
|
||||||
|
- `checkSessionOptions`'s docstring describes one case, `maxOutstanding: 0`, not the function, which misleads slightly.
|
||||||
|
- AGENTS.md's 0.4.0 defect table and its long test-fixture paragraph cost reading time and did not help with `src/`.
|
||||||
|
- **Small duplication noticed:** `collectSent` in `send-sms.ts` and `collectReceipt` in `sms.ts`, and `quoted()` in both `session-options.ts` and `bind-direction.ts`.
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
|
||||||
|
| Dimension | Score | Anchor and cause |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Navigation | 8 | Between 7 and 9. The Architecture map and truthful file names took me from symptom to file first try every time. The lifecycle behaviour spread over `Session`, `LinkLife` and `ReconnectLoop` is what keeps it from 9. |
|
||||||
|
| Locality | 6 | Between 5 and 7. Most collaborators are standalone, with injected `now` and dependencies. But whether a link can carry a request, is stopped, or has ended is split across `LinkLife`, `ReconnectLoop`, `OutgoingRequests.canCarry` and order-dependent calls in `Session`, and the handled-message `answered` state runs through closures in three files. |
|
||||||
|
| Shape | 7 | Predictable. Fan-out is bounded per level and nearly every name tells the truth. The exceptions are `ascii` for GSM, `LATIN1` standing for binary, and `isUp()` being true before the first bind. |
|
||||||
|
| Self-sufficiency | 7 | Predictable. Inline comments carry most of the "why" (SMPP section references, peer quirks). The handler bound and failure semantics needed README, and the reasoning behind the link-life decisions sits in a document I could not open. |
|
||||||
|
| Overall | 7 | Predictable, within the cap of lowest dimension plus one. The hard corners are few and I know which to fear, but the lifecycle corner is spread across four files instead of sitting in one marked place. |
|
||||||
|
|
||||||
|
The problem is intrinsically hard: SMPP session semantics, reconnecting with no resends, a draining shutdown, and reassembly under memory bounds. The scores give no bonus for that.
|
||||||
|
|
||||||
|
SCORES nav=8 loc=6 shape=7 self=7 overall=7
|
||||||
|
|
||||||
|
## Draft D, architect seat
|
||||||
|
|
||||||
|
**Comprehension panel report: Architect, inherited (draft-d)**
|
||||||
|
|
||||||
|
**Order note:** I read AGENTS.md right after README and the tree, before I had written the map down. Its architecture listing matched the map below and changed nothing in it. I opened no test files.
|
||||||
|
|
||||||
|
### 1. Map (README + tree only, verbatim)
|
||||||
|
|
||||||
|
Top-level areas I expected in `src/`:
|
||||||
|
- **A. Entry points:** `index.ts` for the public surface, `client.ts` for connect and bind with reconnect, `server.ts` for the listener, auth and close.
|
||||||
|
- **B. Session core:** `session.ts` as the hub. `session-options.ts` and `defaults.ts` for options. `bind-direction.ts` for which commands a bind type carries.
|
||||||
|
- **C. Link lifecycle:** `link-life.ts` (up, down or ended?), `link-timers.ts` (enquire_link and idle), `reconnect-loop.ts` (backoff), `pdu-transport.ts` (socket to PDUs).
|
||||||
|
- **D. Outbound:** `send-sms.ts` (split and submit), `outgoing-requests.ts` (the request path), `pending-requests.ts` (seqNr correlation), `send-window.ts` (maxOutstanding), `unanswered-error.ts`.
|
||||||
|
- **E. Inbound:** `incoming-requests.ts` (dispatch), `sms.ts` (the onSms handle), `handled-messages.ts` (probably the "held while the handler runs" bound), `reassembly.ts`, `concat.ts`, `udh.ts`, `message-body.ts`.
|
||||||
|
- **F. Receipts:** `dlr.ts` (parse), `dlr-merger.ts` (messageDlr), `sms-id.ts` (hex/decimal and `<base>-<n>`).
|
||||||
|
- **G. Codec:** `pdu.ts`, `pdu-framer.ts`, `pdu-refusal.ts` (PduRefusedError), `retained-pdu.ts` (?), `defs/*` (spec tables).
|
||||||
|
- **H. Text:** `message.ts` (encode, split, smppTime) and `defs/encodings.ts`.
|
||||||
|
- **I. Utilities:** `result.ts`, `error-from.ts`, `log.ts`, `uuid.ts`, `idle-waiters.ts` (?), `expiring-groups.ts` (?).
|
||||||
|
|
||||||
|
Names that did not give their purpose:
|
||||||
|
- `idle-waiters`: idle peer or idle count?
|
||||||
|
- `retained-pdu`
|
||||||
|
- `expiring-groups`: groups of what?
|
||||||
|
- `handled-messages`: reads as "already handled".
|
||||||
|
- `error-from`
|
||||||
|
- `defaults` vs `session-options`
|
||||||
|
|
||||||
|
README features I could not place, or that were missing:
|
||||||
|
- Goal 9's store is absent, as the README says.
|
||||||
|
- smppTime and smppDate: presumably `message.ts`.
|
||||||
|
- At the repo root, `MIGRATION-NOTES.md` and `DESIGN.md` beside `MIGRATION.md` and `docs/decisions.md`: I cannot tell their purpose apart from the others.
|
||||||
|
|
||||||
|
**Where the map was wrong, and what each correction cost:**
|
||||||
|
- **`handled-messages.ts` (medium cost, and it is the crux of the 3am question).** I guessed "held until `sendResp()`". It actually holds a message until the **handler's promise settles**. `answered` is recorded only to decide the retry refusal.
|
||||||
|
- **`link-life.ts` (medium).** It is not only a state flag. It is also the queue where requests wait for the next link, with two orthogonal state variables, `phase` and `stopped`.
|
||||||
|
- **`expiring-groups.ts` (medium).** It is a shared TTL store whose contract is "enforces neither max nor timeout itself" (`expiring-groups.ts:18`). Each of its three owners re-implements the policy differently. `HandledMessages` uses it for running handlers, which are not groups. `DlrMerger` uses a second instance as a "spent" set.
|
||||||
|
- **Cheap corrections:**
|
||||||
|
- `session-options.ts` also holds `SessionEvents` and all option validation.
|
||||||
|
- `bind-direction.ts` also holds bind-record validation (`checkedBind`) and `undeclaredInterfaceVersion`.
|
||||||
|
- `reassembly.ts` holds `decodeSegments`, which `sms.ts` uses.
|
||||||
|
- `idle-waiters.ts` is "wait until a count reaches 0".
|
||||||
|
- `retained-pdu.ts` copies PDUs off the wire and weighs them.
|
||||||
|
|
||||||
|
### 2. Fan-out by level
|
||||||
|
- **L0, repo root:** 22 entries, 8 of them prose documents. Two documents I could not tell apart by name.
|
||||||
|
- **L1, `src/`: 36 files plus `defs/`, all flat. This is the worst level.** Only names and the AGENTS listing group them into the 9 areas above. Nine is bounded; 37 is not, and the directory gives no help.
|
||||||
|
- **L2, `defs/`:** 7 files, bounded and clear.
|
||||||
|
- **L3, units:**
|
||||||
|
- `Session` wires 9 collaborators and has about 15 methods.
|
||||||
|
- `IncomingRequests` owns 3 things and reaches back into `Session`.
|
||||||
|
- `OutgoingRequests` owns 3.
|
||||||
|
- `LinkLife` has 12 methods over 2 state variables. By method count it is the densest unit.
|
||||||
|
|
||||||
|
### 3. Names
|
||||||
|
**One name over several concepts:**
|
||||||
|
- **idle** means four things:
|
||||||
|
- `IdleWaiters`: a count falls to 0.
|
||||||
|
- The `LinkTimers` idle timeout: a silent peer.
|
||||||
|
- `ExpiringGroups.idle()`: stop the sweeper.
|
||||||
|
- `SendWindow.idle()`: the drain.
|
||||||
|
- **refusal** means four things:
|
||||||
|
- `pdu-refusal`: an unreadable PDU.
|
||||||
|
- `LinkLife.refusal()`: the session is over.
|
||||||
|
- `OutgoingRequests.refusal()`: a request cannot go out.
|
||||||
|
- The reassembly `Refusal`: `'full' | 'unplaceable'`.
|
||||||
|
- **settle** covers waiters, pending requests, `IdleWaiters.settle` and `HandledMessages.settle()`, which means "wake the drain if empty".
|
||||||
|
- **Shutdown verbs:** `stop`, `end`, `finish`, `drop`, `dropLink`, `linkLost`, `halted`, `isOver`, `isStopped`. `link.stop()` refuses new work while `reconnectLoop.stop()` halts timers: same verb, different meanings.
|
||||||
|
|
||||||
|
**One concept with several names:**
|
||||||
|
- "Answered": `Answer.done` (`sms.ts:81`), `Handled.answered` (`handled-messages.ts`), `answeredAs` and `answeredOnArrival`.
|
||||||
|
- Writing a response: `answer()`, `sendReturn()`, `sendResp()`.
|
||||||
|
- The reassembly octet cap: option `maxOctets` vs `defaults.maxReassemblyOctets`. The option name does not say "reassembly", yet a sibling cap exists (`maxHandledOctets`).
|
||||||
|
- The server's idle timeout: the literal `defaults.idleTimeout` 40 000 vs the client's derived `2 × enquireLinkInterval`.
|
||||||
|
- Two date formatters, `smppDate` and `smppTime.encode`, in `message.ts`, with duplicated pad chains.
|
||||||
|
|
||||||
|
**Misleading:**
|
||||||
|
- `HandledMessages` means "being handled". The README calls them "messages being handled".
|
||||||
|
- `ExpiringGroups` holds running handlers, which are not groups.
|
||||||
|
- `bind-direction.ts` holds more than direction.
|
||||||
|
|
||||||
|
**Copies:**
|
||||||
|
- `quoted()` appears twice (`session-options.ts:78`, `bind-direction.ts:54`).
|
||||||
|
- `collectSent` (`send-sms.ts:274`) and `collectReceipt` (`sms.ts:183`) are near-twins.
|
||||||
|
- An inline `thrown instanceof Error ? … : new Error(String(thrown))` appears three times (`client.ts:89`, `reconnect-loop.ts:80`, `reconnect-loop.ts:136`) instead of `errorFrom()`.
|
||||||
|
|
||||||
|
### 4. What I would restructure, ranked
|
||||||
|
1. **Group `src/` into about 6 folders:** link, outbound, inbound, receipts, codec, text. The AGENTS listing already draws those lines, so this only moves the map from a document into the layout.
|
||||||
|
2. **Give the shutdown/link vocabulary one owner.**
|
||||||
|
- Collapse `LinkLife.phase` and `stopped` into one state enum.
|
||||||
|
- Rename so that "stop" means one thing everywhere.
|
||||||
|
- Move `OutgoingRequests.refusal`'s `pastDrain && isStopped && canCarry` condition (`outgoing-requests.ts:129`) behind one `LinkLife` predicate.
|
||||||
|
3. **Make `ExpiringGroups` enforce its own policy,** or split it into a TTL store and a set. Today three owners re-implement "full", weight and sweep, and its sweeper interval equals its timeout. So expiry is lazy by up to 2× (`expiring-groups.ts:60`): the README's "five minutes" handler cap is really 5–10 minutes when no traffic arrives. That is a plausible claim drift; I derived it from the code and have not verified it.
|
||||||
|
4. **Merge the "answered" state into one place,** so `sms.ts` and `HandledMessages` stop tracking the same fact.
|
||||||
|
5. **Use `errorFrom()` everywhere.**
|
||||||
|
- `reconnect-loop.ts:80` runs `String(thrown)` inside the `.catch` that is meant to contain an application throw. `errorFrom`'s own comment says `String()` can throw.
|
||||||
|
- If it does, `void this.run()` rejects unhandled and `attempting` stays `true`, which wedges the loop. The trigger is a null-prototype object thrown from an application-supplied `ReconnectOptions.connect` or `onConnected`, reachable because `Session` is publicly constructible.
|
||||||
|
- I call this plausible, not verified.
|
||||||
|
|
||||||
|
**What the structure gets right:**
|
||||||
|
- Files are small (all under 420 lines).
|
||||||
|
- Every file name maps to one noun that also appears in `Session`'s fields.
|
||||||
|
- `Session` reads as a table of contents.
|
||||||
|
- Imports point one way.
|
||||||
|
- Hard-rule-1 result types are uniform.
|
||||||
|
- Comments carry the WHY at the line: spec section numbers, peer quirks, past defects.
|
||||||
|
- `defs/` is clean.
|
||||||
|
- `PduFramer`, `PendingRequests`, `SendWindow` and `ReconnectLoop` each fit in the head alone.
|
||||||
|
|
||||||
|
### 5. The 3am question
|
||||||
|
**Symptom:** during a graceful shutdown the session hangs until `shutdownTimeout`, although the application called `sendResp()` on every message.
|
||||||
|
|
||||||
|
**Path, cold, about 2–3 minutes:** `Session.close` → `drain` (`session.ts:265`) → `this.incoming.drain(...)` (`session.ts:274`) → `IncomingRequests.drain` (`incoming-requests.ts:163`) → `HandledMessages.idle` (`handled-messages.ts:111`).
|
||||||
|
|
||||||
|
**The unit is `HandledMessages.run` (`handled-messages.ts:125`).** The entry is deleted at `:138` only after `await this.handle()` returns. `sendResp()` only flips `handled.answered`, and the drain never reads it.
|
||||||
|
|
||||||
|
**So the peer's handler has not returned.** The likely cause is that it is awaiting `sms.sendDlr()`. That call waits for every `deliver_sm_resp`, up to `responseTimeout`, which defaults to 30 s, longer than the 5 s shutdown. It can also wait without bound behind a full send window, because `sendDlr` passes no signal.
|
||||||
|
|
||||||
|
This is designed behaviour: the `OnSms` type doc, README lines 108 and 389, and the AGENTS decision all say it. The fix is on the caller's side (return the handler, or fire-and-forget the receipt). The code states this at the type (`session-options.ts:39`), so no document is needed.
|
||||||
|
|
||||||
|
**Where it rots first:**
|
||||||
|
- The link/shutdown triangle: `Session.drain`/`finish`/`linkLost`/`dropLink`/`comeBackUp` plus `LinkLife` plus `OutgoingRequests.refusal`. Three units read `LinkLife` state. Correctness depends on call order: `link.stop()` before `canCarry()`, `drop()` before `end()`. `IncomingRequests` also snapshots `link.generation()`.
|
||||||
|
- Next, `ExpiringGroups` and its three divergent owners.
|
||||||
|
|
||||||
|
**Where the next two features land:**
|
||||||
|
- **Goal 9's store** would have to thread an interface through `Session` → `IncomingRequests` → `Reassembler`/`HandledMessages`/`DlrMerger`, which is every `ExpiringGroups` owner. That is the costliest seam in the code base.
|
||||||
|
- **A per-PDU rate-limit hook (goal 7)** lands cleanly in `OutgoingRequests.request` beside `SendWindow.acquire`.
|
||||||
|
|
||||||
|
### 6. Hardest places, ranked
|
||||||
|
1. `session.ts:265-362`: `drain`, `finish`, `linkLost`, `dropLink`, `comeBackUp`. Order-dependent shutdown across 4 collaborators.
|
||||||
|
2. `link-life.ts:31` `LinkLife` as a whole: `phase` × `stopped`, and 7 predicates with overlapping meanings.
|
||||||
|
3. `outgoing-requests.ts:74-134`, `request` and `refusal`: the link-wait/window retry loop and the `pastDrain` exemption.
|
||||||
|
4. `handled-messages.ts:67-138`, `refuses`/`offer`/`run`: hysteresis, a hidden sweeper timer, and the answered flag written from `sms.ts`.
|
||||||
|
5. `expiring-groups.ts` `weigh` together with `reassembly.ts:187` `trim`: eviction can take the current key.
|
||||||
|
6. `pdu.ts:84-136`, `resolveShortMessage` and `resolveBody`: the `CodingSource` rules.
|
||||||
|
7. `incoming-requests.ts:218-260`, `onMessage` and `offer`: the bound check, reassembly, answer on arrival, and generation capture.
|
||||||
|
8. `dlr.ts:216` `dlrFromPdu`: the `messageType`/`receiptStatus` precedence.
|
||||||
|
|
||||||
|
**The unit I would least want to modify is `LinkLife`.** Everything that decides whether a request may go out reads it, and its meaning is spread over 7 predicates.
|
||||||
|
|
||||||
|
**Intrinsic difficulty:** high. It is an SMPP session layer with reconnect, a send window, reassembly, receipt merging and drain semantics, and it earns no bonus for that.
|
||||||
|
|
||||||
|
### 7. Scores
|
||||||
|
- **Navigation 7:** "Predictable". The file names plus `Session`'s field list got me from symptom to unit in 3 hops. The flat 37-file `src/` and the misleading name `HandledMessages` keep it below 9.
|
||||||
|
- **Locality 6:** between "honest middle" and "predictable". `LinkLife` state is read by `Session`, `OutgoingRequests` and `IncomingRequests`, and shutdown correctness depends on call order. `ExpiringGroups` pushes its own policy onto three owners.
|
||||||
|
- **Shape 6:** between "honest middle" and "predictable". Units are small and mostly named truthfully. But L1 has 37 ungrouped entries, and "idle", "refusal", "settle" and "stop" are each overloaded.
|
||||||
|
- **Self-sufficiency 7:** "Predictable". Comments at each unit state its invariants and the spec section behind them, and the 3am answer is readable at the `OnSms` type with no document open. A reader still needs the AGENTS listing to see the area grouping that the layout does not show.
|
||||||
|
- **Overall 6:** capped by locality and shape at 6. A cold senior is productive within a week on everything except the link/shutdown triangle.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=7 overall=6
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
# Round 3: drafts E and F
|
||||||
|
|
||||||
|
## Draft E, junior seat
|
||||||
|
|
||||||
|
1. **Hardest places, hardest first**
|
||||||
|
|
||||||
|
1. **`src/session-life.ts:175` `SessionLife.attempt()`, with `enter()` at `:115` and `linkLost()` at `:109`.** The async reconnect continuation re-enters the state machine after two awaits. It snapshots `this.links` and later compares it, and it reads state through `is()` only to stop TypeScript narrowing. The step to `bound` is not in this file: it goes `rebind` → `client.ts` `bind()` → `Session.bound()` → `life.bound()`, three files away. There is also hidden re-entrancy. `effects.linkDown()` calls `sock.destroy()`, which fires the transport's `onClose` → `life.linkLost()`. That call is harmless only because `attached()` is already false. The ASCII diagram at `:8-24` and the comment "a transition from any other state is ignored" resolved most of it. Why `links` could change during `rebind` stayed opaque.
|
||||||
|
2. **`src/outgoing-requests.ts:97` `request()`, `sendOnce()` at `:116` and `attempt()` at `:162`.** There is a `for(;;)` retry around three nested waits: link, window slot, response. Each has its own deadline or timeout semantics, and `written` plus `state() !== 'down'` decide whether to loop. The `misuse()` check runs here and again in `Session.send()` (`session.ts:180`), and the reason for the duplicate is a riddle comment ("named as one ahead of the drain"). The JSDoc on `request()` and the `UnansweredError` naming resolved the intent. I had to read `README` "Sends and the link" to trust it.
|
||||||
|
3. **`src/handled-messages.ts:61` `refuses()` and `:120` `run()`.** `refuses()` looks like a predicate but sweeps, logs, and flips `atBound` hysteresis. `run()` answers the peer after the handler, and the answer depends on whether `sms.answered` was flipped by a closure inside `sms.ts`. It then removes the entry only if `running.get(key) === sms`, because a sweep may already have dropped it while the handler keeps running. `ExpiringGroups` gets `max` here but, by its own doc, does not enforce it, so I had to go and read `expiring-groups.ts` to know who does.
|
||||||
|
4. **`src/reassembly.ts:186` `trim()`, with `ExpiringGroups.weigh()` at `src/expiring-groups.ts:70`.** `weigh()` evicts the oldest groups and may return the current key itself. Then `answered = parts.size - 1` subtracts the just-arrived segment, because that one gets a `full` refusal and the peer keeps it. Holding "set never evicts, weigh does, owners check full" across two files cost two rereads. The inline comments resolved it.
|
||||||
|
5. **`src/sms.ts:82` `createSms()` and `:126` `sendResp()`.** A mutable `answer` record is captured in a closure and exposed through getters. `link` is the socket at arrival, compared by `destroyed` rather than against `session.sock`. `answeredAs` means "multipart, already answered on arrival". I only understood why `sendResp()` on a multipart message is a no-op after reading README "Server in depth" (answered on arrival). The code alone did not tell me.
|
||||||
|
6. **`src/pdu.ts:84` `resolveShortMessage()` and `:113` `resolveBody()`.** The `CodingSource` idea is hard for someone without SMPP: which of `short_message` and `message_payload` gets to set `data_coding`, and when an empty buffer counts as "payload". Also, `readOptionalParams()` at `:267` retries parsing with one skipped NULL. The comments are accurate but assume the domain. README "Building" and the SMPP terms table were needed.
|
||||||
|
7. **`src/defs/encodings.ts:152-191` `messageClassOf()`, `messageClassEncoding()`, `encodingByDataCoding()`.** Bit masks (`0x80`, `0x10`, `0xF0`, `>> 2 & 0x03`) against GSM 03.38 coding groups I have never seen. The comments cite spec sections I cannot open. This stayed opaque. I trust it only because the tests presumably pin it; I did not open them.
|
||||||
|
8. **`src/client.ts:240` `retryUntilBound()` and `:278` `keepTrying()`.** This is a second backoff loop, separate from `SessionLife`'s. Its `settle` callback is called on every failure and does not settle anything; it only records `lastErr`. The name misled me until I read the callback body at `:290`. AGENTS' architecture line ("the first-connect retry of reconnect.fromStart") told me why it exists.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify:** `SessionLife.enter()` / `attempt()` (`src/session-life.ts:115-203`). Every lifecycle effect fans out from it through `LifeEffects` closures defined in `session.ts:260`. Those closures call back into the transport, whose socket events call `life.linkLost()` again. Correctness rests on "ignored from any other state" and on the ordering of effects before emit. I could not predict what a new transition would re-trigger without running it.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:**
|
||||||
|
- `PduFramer`: small, one job, and the quadratic-avoidance comment explains the only trick.
|
||||||
|
- The wire-type table in `defs/commands.ts`, where the wire-order warning sits right at the table.
|
||||||
|
- The result pattern and "nothing throws": consistent everywhere, so no surprises.
|
||||||
|
- `defaults.ts`: one place, grouped.
|
||||||
|
- `sms-id.ts`, `concat.ts`, `message-body.ts`, `log.ts`, `pdu-refusal.ts`: each read cold in one pass.
|
||||||
|
- `send-sms.ts`: long but linear, a chain of `check*` functions.
|
||||||
|
- The AGENTS architecture map, which got me to the right file first time for every question I had.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **Documentation I needed:**
|
||||||
|
- README "SMPP terms" table: essential and cheap to find, linked from the table of contents.
|
||||||
|
- README "Server in depth" and "Receiving in depth", for answered-on-arrival and the handled-message bound. The rules behind `sms.ts` and `handled-messages.ts` live there, not in the code.
|
||||||
|
- AGENTS architecture list: essential for navigation.
|
||||||
|
- The AGENTS decisions index lines are cryptic without `docs/decisions.md`, which I was not allowed to open (for example "A report is final unless its `esm_class` or its state says otherwise").
|
||||||
|
- Spec knowledge (`esm_class` bits, `data_coding` groups) is cited by section number and never explained. That cost the most and was never repaid.
|
||||||
|
- **Prose that told me nothing the code did not already say:**
|
||||||
|
- The duplicated deliver JSDoc in `outgoing-requests.ts:84` and `pending-requests.ts:57`.
|
||||||
|
- `/** A socket is on the link. */` on `attached()`.
|
||||||
|
- The AGENTS "Defects found in 0.4.0" table, which is history and not a guide to the current code.
|
||||||
|
- The AGENTS test conventions, which are irrelevant to reading `src/`.
|
||||||
|
- A different cost: many comments are compressed to riddles and needed several reads each. Examples are "Announced when the wait is over rather than when it starts: a cancelled one never happened." (`session-life.ts:160`) and "A misuse is named as one ahead of the drain, rather than blamed on the shutdown."
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
- **Navigation 7:** at the "Predictable" anchor. The AGENTS file map and honest file names (`pdu-framer`, `link-timers`, `sms-id`) took me symptom → file first try. It stops short of 9 because reconnect lives in two places (`session-life.ts` and the separate `client.ts` retry loop).
|
||||||
|
- **Locality 5:** at the "Honest middle" anchor. Collaborators are wired by closures back into `Session` (`LifeEffects`, `PduTransport` callbacks, `IncomingRequests` calling `session.emit`, `.sock` and `.close`). The `Sms` answer record is mutated from two modules. Safe changes in `SessionLife` and `HandledMessages` need the whole session held in your head.
|
||||||
|
- **Shape 6:** between 5 and 7. Fan-out per level is bounded and the files are small. Names overload or lie, though:
|
||||||
|
- `settle` means four different things (`IdleWaiters`, `PendingRequests`, `SendWindow`, the `client.ts` callback that settles nothing).
|
||||||
|
- `handlers` in `IncomingRequests` means `{answer, send}`, not `onSms`.
|
||||||
|
- `refuses()` and `closing()` read as predicates but mutate.
|
||||||
|
- `SessionLife.carries()` is dead: unused in `src/`, duplicated by the switch in `OutgoingRequests.waitForLink()`.
|
||||||
|
- **Self-sufficiency 5:** at the "Honest middle" anchor. The generic parts (framer, codec types, results, timers) stand alone. The session semantics (answered-on-arrival, the handled-message bound) and all the `data_coding`/`esm_class` bit logic needed README sections or the spec open beside the code.
|
||||||
|
- **Overall 5:** as someone new to the domain, I would take an area in a day or two, with rereads and some wrong turns. The problem's intrinsic difficulty is genuinely high (a protocol state machine, reassembly, and receipt correlation over a flaky link), and it gets no bonus here.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=5 shape=6 self=5 overall=5
|
||||||
|
|
||||||
|
## Draft E, mid seat
|
||||||
|
|
||||||
|
1. **Hardest places, hardest first**
|
||||||
|
|
||||||
|
1. **The answer to an inbound message**, `incoming-requests.ts:218` (`IncomingRequests.onMessage`), `handled-messages.ts:120` (`HandledMessages.run`) and `sms.ts:126` (`sendResp`), with `sms.ts:82` (`createSms`) holding the `Answer` record.
|
||||||
|
- Whether the peer has been answered, and under which id, is decided in three places:
|
||||||
|
- `onMessage` answers multipart segments one by one on arrival and passes `answeredAs`.
|
||||||
|
- `createSms` turns `answeredAs` into a pre-set `status: 'ESME_ROK'`.
|
||||||
|
- `run` answers after the handler only when `!sms.answered`, choosing the retry status if the handler failed.
|
||||||
|
- "Which link" is carried as a `Socket` identity that is compared through `.destroyed` in two places (`IncomingRequests.handle` and `sendResp`).
|
||||||
|
- To reason about "handler throws after a multipart message" I had to keep all three files in my head. The `Sms` type docs and the README section "Server in depth" resolved it. It is followable but not local.
|
||||||
|
2. **`ExpiringGroups` and the classes built on it**, `expiring-groups.ts:19`, with `reassembly.ts:187` (`Reassembler.trim`) and `dlr-merger.ts:105/150/166` (`collect`, `open`, `spend`).
|
||||||
|
- The store refuses to enforce its own `max` and `timeout` ("owners check full and call takeExpired(); only weigh() evicts"). So each of the three owners re-implements the capping itself (`open` → `dropOldest`, `sweep` before `collect`).
|
||||||
|
- `weigh()` can evict the very key being weighed. `trim` then does `answered = parts.size - 1` for that case, and I needed two reads to see why.
|
||||||
|
- `DlrMerger` runs a second `ExpiringGroups<true>` (`spent`) as a tombstone set, with `spend()` writing to both stores.
|
||||||
|
- The doc comments stop you misusing the store, but understanding any one owner means holding the store's partial contract.
|
||||||
|
3. **`resolveShortMessage` / `resolveBody`**, `pdu.ts:84` and `pdu.ts:113`.
|
||||||
|
- `CodingSource` decides whether `short_message` or `message_payload` may set `data_coding`. That depends on Buffer vs string vs empty, and on whether the command's table has a `short_message` at all.
|
||||||
|
- I had to hold four or five branches at once, and `data_coding` gets rewritten in two different spots.
|
||||||
|
- The type comment on `CodingSource` helped. The rule behind it ("a string body is written in the alphabet its own data_coding names") is only a title in the AGENTS index.
|
||||||
|
4. **The retry loop in `OutgoingRequests.request` / `sendOnce`**, `outgoing-requests.ts:97` and `:116`.
|
||||||
|
- `sendOnce` returns `undefined` to mean "loop again". Whether to loop depends on `attempt.written` and on a live read of `state() !== 'down'` after two awaits.
|
||||||
|
- With `LinkWaiters`, `SendWindow` and `PendingRequests` underneath, a send passes through four waits with three different abort and timeout rules.
|
||||||
|
- The doc comments on `request` and `Attempt.written` resolved it, as did the README bullets under "Sends and the link".
|
||||||
|
5. **`SessionLife.enter` / `attempt`**, `session-life.ts:115` and `:175`.
|
||||||
|
- The ASCII state diagram is very good. What cost me was `attempt()`: it re-checks `is('down')` after `connect`, then `this.links !== link || !is('connected')` after `rebind`.
|
||||||
|
- `rebind` calls `Session.bound()`, which calls `life.bound()` → `enter('bound')`. That is a re-entrant path back into the machine from inside an await.
|
||||||
|
- The comment "the one continuation that re-enters the machine" marks it, and the diagram resolved it.
|
||||||
|
6. **`Session.unbind` / `drain`**, `session.ts:218` and `:317`.
|
||||||
|
- `life.closing()` is a query-named method that performs the transition.
|
||||||
|
- `closedOnUnbind = wasOpen && !this.life.attached()` infers that the peer dropped the link in answer to our unbind.
|
||||||
|
- The return value orders two errors with different priorities.
|
||||||
|
- The doc comment helped. The mutating `closing()` still surprised me.
|
||||||
|
7. **The two reconnect loops**, `client.ts:240` (`retryUntilBound`) and `client.ts:278` (`keepTrying`).
|
||||||
|
- AGENTS says "the reconnect loop is the `down` state", but `fromStart` is a second, hand-rolled backoff loop in `client.ts`. The charter's file list does mention it.
|
||||||
|
- The callback named `settle` is called on every failed attempt and does not settle: it only records `lastErr`. That name misled me until I read the body of `keepTrying`.
|
||||||
|
8. **The overloaded third argument of `WireType.read`**, `pdu.ts:249` (`readParams` passes `sm_length` to every param reader) and `defs/types.ts:280` (`tlvInt`).
|
||||||
|
- For a mandatory parameter it is `sm_length`; for a TLV it is the TLV header length.
|
||||||
|
- Only `buffer` and the tlv variants use it, and nothing names the dual meaning. I found it by grepping callers. It stayed half-opaque until then.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify:** `Reassembler.collect` + `trim` together with `ExpiringGroups.weigh`.
|
||||||
|
- Weight accounting is spread across `set` (zeroes the weight), `weigh` (evicts, possibly the caller's own key) and `trim` (recomputes the group total from scratch).
|
||||||
|
- Every refusal path decides a peer-visible status, and a lost group is reported to the application as traffic gone. An off-by-one there is silent data loss.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy:**
|
||||||
|
- The codec tables (`defs/commands.ts`, `defs/tlvs.ts`) and the TLV typing, including `tlvSpecs` keying each definition to its own name.
|
||||||
|
- `PduFramer` and `PduTransport`.
|
||||||
|
- The DLR parsing in `dlr.ts`: every regex and every fallback has a one-line reason.
|
||||||
|
- `SessionLife` itself, thanks to the diagram.
|
||||||
|
- GSM packing (153 vs 134). The `segmentUnits` comment, plus the AGENTS section "GSM 7-bit is sent unpacked", made it obvious even to someone who knows nothing about SMPP.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **What I needed and what it cost:**
|
||||||
|
- The README "SMPP terms" table (ESME/SMSC, `esm_class`, UDH, `sar_*`). Cheap to find, essential without domain knowledge.
|
||||||
|
- The README sections "Sends and the link", "Receiving in depth" and "Server in depth", to confirm the intent behind items 1 and 4. Each took a scroll-and-search.
|
||||||
|
- The AGENTS architecture map, for navigation.
|
||||||
|
- Several `// SMPP 3.4 x.y.z` comments point at a spec I have never read, and I had to take them on trust. Examples: `respIdParams`, `refusalAnswer`, `messageClassOf`.
|
||||||
|
- The AGENTS decision index gives titles only. Twice (the drain ordering, and `data_coding` ownership in the codec) the title told me a rule existed without telling me the rule, and the reasoning lives in `docs/decisions.md`, which I was told not to open.
|
||||||
|
- I opened no tests.
|
||||||
|
- **Prose that told me nothing the code did not:**
|
||||||
|
- For reading `src/`: the AGENTS test-conventions bullets (fixtures, `resume()`, `t.after` ordering) and the "Defects found in 0.4.0" table, which is history about another codebase.
|
||||||
|
- The README Goals and Audience.
|
||||||
|
- A few restating doc comments: `PendingRequests.deliver` ("False means nobody was"), `LinkTimers.clear`'s neighbours, `defs/index.ts`'s grouping.
|
||||||
|
- The duplicate `emit` / `captureRejectionSymbol` guard comments in `session.ts` and `server.ts`.
|
||||||
|
|
||||||
|
5. **Scores.** Intrinsic difficulty is moderate to high (wire protocol, reconnect, backpressure, multipart), and it gets no bonus below.
|
||||||
|
- **Navigation 8.** Between 7 and 9: the AGENTS file map plus one concept per file (`dlr-merger.ts`, `link-waiters.ts`, `pdu-refusal.ts`) got me from a symptom to the right file cold every time. The one detour was the second reconnect loop living in `client.ts`.
|
||||||
|
- **Locality 6.** Between 5 and 7: `SessionLife` does centralise the state, but the answered-or-not state spans `IncomingRequests`, `HandledMessages` and `Sms`. `ExpiringGroups` also pushes enforcement of its own invariants onto three owners, and link identity is a shared `Socket` reference compared across modules.
|
||||||
|
- **Shape 7.** Predictable: fan-out is bounded (`Session` wires about six collaborators through narrow option objects) and most names tell the truth. It is held there by a few that lie or hide effects: `closing()` mutates, the `settle` callback in `keepTrying` does not settle, and `IncomingRequests.clear()` also empties the handled messages.
|
||||||
|
- **Self-sufficiency 7.** Predictable: nearly every non-obvious line carries a one-line why, often with a spec reference, and the hard corners are marked. It stays below 9 because several rules (codec `data_coding` ownership, drain ordering) exist in the code as outcomes whose reasons are only indexed titles, and the domain vocabulary needs the README glossary open.
|
||||||
|
- **Overall 7.** Capped at 7 by Locality 6. A cold mid-level reader knows within a day which corners to fear (items 1, 2 and 3 above). They are few and marked, but item 1 is harder than the problem requires.
|
||||||
|
|
||||||
|
SCORES nav=8 loc=6 shape=7 self=7 overall=7
|
||||||
|
|
||||||
|
## Draft E, senior seat
|
||||||
|
|
||||||
|
**Comprehension panel report: senior maintainability seat, `@larvit/smpp` (draft-e), whole project**
|
||||||
|
|
||||||
|
I read `README.md`, `AGENTS.md` and every file under `src/`, including `defs/`. I opened no tests.
|
||||||
|
|
||||||
|
## 1. Hardest places, hardest first
|
||||||
|
|
||||||
|
1. **`src/reassembly.ts:187` `Reassembler.trim()`, and `src/expiring-groups.ts:70` `ExpiringGroups.weigh()`**
|
||||||
|
- `weigh()` can evict the group that is being weighed. `trim()` then has to work out whether that group survived. It also counts only `parts.size - 1` segments as lost when that group is the victim, because the new segment "stays with the peer".
|
||||||
|
- To get this right I had to hold four things at once: `weigh`'s eviction order, `takeOldest → delete → idle`, the "answered" arithmetic, and the caller in `collect()`, which answers `full`.
|
||||||
|
- The inline comments made it clear in the end, after two passes.
|
||||||
|
|
||||||
|
2. **`src/expiring-groups.ts:19` `ExpiringGroups`, as a contract**
|
||||||
|
- The class docstring says it "enforces neither max nor timeout itself", so every owner has to call `full` and `takeExpired()` in the right order.
|
||||||
|
- `HandledMessages` stretches this across two classes. `IncomingRequests.onMessage` (`incoming-requests.ts:218`) calls `handled.refuses()` before reassembly, and `offer()` comes later, when the message is whole.
|
||||||
|
- Nothing states that the cap holds only because `refuses()` ran first. I had to reconstruct that myself, and it stayed implicit.
|
||||||
|
|
||||||
|
3. **`src/session-life.ts:115` `SessionLife.enter()` / `:175` `attempt()`, with `src/session.ts:260` `lifeFor()`**
|
||||||
|
- The ASCII transition table in the header is the best piece of prose in the repo.
|
||||||
|
- Two things cost me:
|
||||||
|
- The header says "a transition from any other state is ignored", but `enter()` has no guard. The guards live in the public wrappers (`bound()`, `closing()`, `linkLost()`, `end()`), so I had to check each one.
|
||||||
|
- The effects are closures defined in `Session`. To follow `down → connected → bound` I had to jump between `session-life.ts`, `session.ts` `lifeFor`/`linkDown`, `OutgoingRequests.linkUp`/`linkLost` and `PduTransport.attach`.
|
||||||
|
- `attempt()`'s re-check after the await (`this.links !== link || !this.is('connected')`) is commented and fine.
|
||||||
|
|
||||||
|
4. **`src/client.ts:240` `retryUntilBound()` / `:278` `keepTrying()`**
|
||||||
|
- This is a second backoff loop, separate from `SessionLife`'s. The charter and README say `fromStart` goes "through that same loop", but it doesn't: it's a separate loop that uses the same `Backoff` class.
|
||||||
|
- The callback type is named `Settle`, but on an error it doesn't settle anything; it only records `lastErr`. You learn that from a comment inside the lambda in `keepTrying`.
|
||||||
|
- Add the `waiting()` closure and an abort listener, and this is four nested pieces of control flow for one feature. It resolved in the end, but the name misled me along the way.
|
||||||
|
|
||||||
|
5. **`src/pdu.ts:84` `resolveShortMessage()` / `:113` `resolveBody()`**
|
||||||
|
- The `CodingSource` rule decides which of `short_message` and `message_payload` may rewrite `data_coding`. It depends on whether the command's table has a `short_message` at all, on Buffer vs string, and on zero length.
|
||||||
|
- The comment on the `CodingSource` type states the rule, but I had to trace three return shapes to confirm it.
|
||||||
|
- Next to it, `readParams()` (`:240`) passes `sm_length` as the length argument to every param read. That works only because `sm_length` comes earlier in wire order. `commands.ts` documents wire order, but not that this depends on it.
|
||||||
|
|
||||||
|
6. **`src/sms.ts:126` `sendResp()`, with `src/session.ts:304` `answer()`**
|
||||||
|
- `sendResp` checks the socket the message arrived on, `link.destroyed`. `answer()` then writes to `transport.sock`, the current socket.
|
||||||
|
- This is correct only because a socket is replaced only after it has been destroyed (`PduTransport.attach`).
|
||||||
|
- `IncomingRequests.handle()` (`:104`) relies on the same equivalence. It is not stated anywhere I read.
|
||||||
|
|
||||||
|
7. **`src/handled-messages.ts:120` `HandledMessages.run()`**
|
||||||
|
- Covers expiry, a handler failure, answering after the handler settles, and the identity check `running.get(key) !== sms`, which catches a `clear()` or sweep that ran in the meantime.
|
||||||
|
- The class docstring covers it. It is compact but dense.
|
||||||
|
|
||||||
|
8. **`src/defs/encodings.ts:163` `messageClassEncoding()` / `encodingByDataCoding()`**
|
||||||
|
- Bit-twiddling over GSM 03.38 coding groups that I had no background for.
|
||||||
|
- The comments are adequate. The problem is inherently hard; it is not badly written. Stacking a `//` comment on top of a `/** */` comment made it unclear which one belonged to which function.
|
||||||
|
|
||||||
|
## 2. The unit I would least want to modify
|
||||||
|
|
||||||
|
`Reassembler.trim()` together with `ExpiringGroups.weigh()`.
|
||||||
|
|
||||||
|
- The eviction, the "is the current group gone" question, the lost-segment count and the ESME-facing status (`full` → throttled) are all spread over two files, and each file assumes the other's behaviour.
|
||||||
|
- A wrong change here silently loses traffic the peer will never resend, which is the README's worst outcome.
|
||||||
|
- Nothing in the code would stop me. Only a test would catch the mistake.
|
||||||
|
|
||||||
|
## 3. Expected hard, found easy
|
||||||
|
|
||||||
|
- **Framing (`pdu-framer.ts`):** short, and the quadratic-join rationale is right there.
|
||||||
|
- **The send path:** `OutgoingRequests.request/sendOnce/attempt`. The `written` flag makes "resend or not" a single boolean.
|
||||||
|
- **`PendingRequests` and `SendWindow`**
|
||||||
|
- **The TLV table's self-keyed generic**
|
||||||
|
- **Receipt parsing (`dlr.ts`)**
|
||||||
|
- **`splitMessage`:** the budget comment together with the charter's "GSM 7-bit is sent unpacked" section settled the 153/134 question at once.
|
||||||
|
|
||||||
|
## 4. Prose debt
|
||||||
|
|
||||||
|
**Needed:**
|
||||||
|
- The `session-life.ts` state table: essential, and cheap to find.
|
||||||
|
- `AGENTS.md`'s file map: accurate, and the fastest route from a symptom to a file.
|
||||||
|
- The charter's "GSM 7-bit is sent unpacked" section.
|
||||||
|
- README "Server in depth", to understand why segments are answered on arrival.
|
||||||
|
|
||||||
|
**Missing:**
|
||||||
|
- The invariants in items 2 and 6. They are written down nowhere I was allowed to read.
|
||||||
|
- The decisions index points at `docs/decisions.md`, which I couldn't open. Twice (the `fromStart` "same loop" wording, and the abort-dance duplication) the one-line index entry made a claim the code did not obviously bear out, and there was nothing local to check it against.
|
||||||
|
|
||||||
|
**Prose that told me nothing new:**
|
||||||
|
- The `/** Injected so expiry can be exercised without a wall clock. */` comment, repeated on four option types.
|
||||||
|
- `// Called unbound, so the application's hook never sees this class as its this` (`incoming-requests.ts`).
|
||||||
|
- Most of the charter's test-convention paragraph (fixtures, `recordingDeps`), which is irrelevant for reading `src/`.
|
||||||
|
- Much of the defect table, as far as reading `src/` goes.
|
||||||
|
|
||||||
|
## 5. Scores
|
||||||
|
|
||||||
|
- **Navigation 7:** Predictable. The `AGENTS.md` file map is accurate line by line, and file names match their content (`link-waiters`, `pdu-refusal`, `sms-id`), so I landed first try on every symptom I tried. It doesn't reach 9 because `defaults` are applied in three different layers (`Session`, `IncomingRequests`, `Reassembler`), so finding "where does this default apply" takes a search.
|
||||||
|
- **Locality 6:** Between honest middle and predictable. It is held down by three unwritten cross-class invariants: owners enforce `ExpiringGroups`' limits, `refuses()` must run before `offer()`, and a destroyed arrival socket stands in for the current socket. `SessionLife`'s effects are also closures back into `Session`.
|
||||||
|
- **Shape 7:** Predictable. Every file is small, fan-out per level is bounded, and names mostly tell the truth. It is kept from 8 by a few names that mislead:
|
||||||
|
- `closing()` is a transition, not a predicate.
|
||||||
|
- `Settle` doesn't settle on an error.
|
||||||
|
- `bound()` exists on two layers with different contracts.
|
||||||
|
- `onRequest` names both the server's wrapper and the application's hook.
|
||||||
|
- **Self-sufficiency 7:** Predictable. The why-comments sit on the lines that need them, and spec section numbers are cited. It is held back by a charter index that points to a decisions file whose claims I could not check locally.
|
||||||
|
- **Overall 6:** The capped maximum is 7 (lowest dimension plus one). I gave 6 because the costs are concentrated in exactly the bounded-store and link-identity code where a mistake loses traffic.
|
||||||
|
|
||||||
|
The problem itself is moderately hard: flow control across two peers, the SMPP body and encoding rules, and reconnect races. That earns no bonus.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=7 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft E, architect seat
|
||||||
|
|
||||||
|
# Comprehension panel, seat "Architect, inherited": @larvit/smpp (draft-e)
|
||||||
|
|
||||||
|
I read README.md, AGENTS.md and every non-test file in src/. I opened no test, ran nothing, edited nothing and ignored the pre-loaded AGENTS.md.
|
||||||
|
|
||||||
|
## 1. Map from README.md and `ls -R src test` only (verbatim)
|
||||||
|
|
||||||
|
> Flat `src/` of 36 files plus `defs/` (7). I expect seven areas that the layout does not show:
|
||||||
|
> A. **Spec tables**, `defs/`: commands, constants, encodings, errors, TLVs, wire types, plus an index grouping them.
|
||||||
|
> B. **Codec**: `pdu.ts` (pduToObj/objToPdu), `pdu-framer.ts` (stream to PDUs), `pdu-refusal.ts` (PduRefusedError), `retained-pdu.ts` (?), `result.ts`, `error-from.ts` (?).
|
||||||
|
> C. **Message content**: `message.ts` (encode/split/bitCount, and probably smppTime), `message-body.ts`, `concat.ts`, `udh.ts`, `reassembly.ts`, `expiring-groups.ts` (a TTL map, probably under reassembly).
|
||||||
|
> D. **Receipts**: `dlr.ts` (parse *and* build receipts), `dlr-merger.ts` (messageDlr), `sms-id.ts` (hex/decimal ids).
|
||||||
|
> E. **Session core**: `session.ts`, `session-life.ts` (?), `session-options.ts` (types), `defaults.ts`, `link-timers.ts` (enquire_link/idle), `backoff.ts` (reconnect loop?), `pdu-transport.ts`, `link-waiters.ts` (?), `idle-waiters.ts` (?).
|
||||||
|
> F. **Request flow**: `outgoing-requests.ts`, `pending-requests.ts` (seq/correlation), `send-window.ts`, `unanswered-error.ts`, `send-sms.ts`, `incoming-requests.ts`, `sms.ts` (the onSms handle), `handled-messages.ts` (messages whose onSms runs, for the bound and the drain).
|
||||||
|
> G. **Entry points**: `client.ts`, `server.ts`, `index.ts`, `log.ts`, `uuid.ts`.
|
||||||
|
> Names that do not give their purpose: session-life, link-waiters vs idle-waiters, retained-pdu, error-from, defaults vs session-options, and backoff (a loop or only a delay?).
|
||||||
|
> Expected from the README but missing: the goal-9 store (the README says it has not shipped). At the repo root, MIGRATION-NOTES.md and DESIGN.md sit beside the six documents the charter names, with no stated purpose.
|
||||||
|
|
||||||
|
### Corrections, cheapest first
|
||||||
|
|
||||||
|
| Map guess | Reality | Cost |
|
||||||
|
|---|---|---|
|
||||||
|
| backoff.ts is the reconnect loop | Delay arithmetic only. The loop is the `down` state of `SessionLife`. | Low. The AGENTS table says so. |
|
||||||
|
| link-waiters / idle-waiters | Requests waiting for a bound link / a count falling to zero | Low |
|
||||||
|
| dlr.ts parses and builds receipts | Building lives in `sms.ts` (`receiptText`, `sendDlr`, `collectReceipt`) | Medium. I went to dlr.ts first, then grepped for `stat:`. |
|
||||||
|
| session-options.ts is option types | Also holds `SessionEvents`, bind-direction logic (`bindCarries`, `standsInFor`, `bindCommands`) and all option validation (`checkSessionOptions`) | Medium. The name hides three concerns. |
|
||||||
|
| Whole-message decoding lives in message.ts | `decodeSegments` is in `reassembly.ts:63` and is called from `sms.ts` | Low-medium |
|
||||||
|
| One reconnect loop | Two. `SessionLife.attempt/schedule`, plus a second in `client.ts:240` `retryUntilBound`/`keepTrying` for `fromStart`. Both log `'reconnect - retrying'`. | Medium. The same log line from two places is a 3am trap. |
|
||||||
|
| retained-pdu, error-from | Heap-detach and weight of a held PDU; turning a thrown value into an Error | Low. The AGENTS table covers both. |
|
||||||
|
|
||||||
|
## 2. Fan-out by level
|
||||||
|
|
||||||
|
- **Repo root:** about 8 prose documents (README, AGENTS, CHANGELOG, MIGRATION, MIGRATION-NOTES, DESIGN, todo, CLAUDE) plus 5 directories. MIGRATION-NOTES and DESIGN are not in the charter's "each file answers one question" list.
|
||||||
|
- **`src/`, the worst level:** 37 entries. They fall into 7 real areas, but only the AGENTS.md table shows that. Without the table this level is past any bound you can hold at once.
|
||||||
|
- **`src/defs/`:** 7. Good.
|
||||||
|
- **Within the session:**
|
||||||
|
- `Session` holds 8 collaborators.
|
||||||
|
- `OutgoingRequests` holds 3 (PendingRequests, LinkWaiters, SendWindow).
|
||||||
|
- `IncomingRequests` holds 2 (HandledMessages, Reassembler) plus 6 injected fields.
|
||||||
|
- `HandledMessages` holds 2 (ExpiringGroups, IdleWaiters).
|
||||||
|
- Depth is at most 4 and fan-out at most 8 per level. Bounded.
|
||||||
|
- **Files:** most are under 250 lines. `defs/types.ts` (684 lines) is long but uniform: one wire type after another.
|
||||||
|
|
||||||
|
## 3. Names
|
||||||
|
|
||||||
|
**Misleading:**
|
||||||
|
- `SessionLife.closing()` (`session-life.ts:97`) reads like a predicate but performs the transition and returns whether it did. `carries()` and `attached()` next to it really are predicates.
|
||||||
|
- `session-options.ts` holds events, bind direction and validation as well as options.
|
||||||
|
- The comment on `SessionOptions.shutdownTimeout` (`session-options.ts:117`) says "How long a drain waits for the requests already on the wire". It also bounds the wait on running handlers (`session.ts:324`). That comment is false.
|
||||||
|
- In `client.ts:234,288`, `Settle` is called once per failed attempt as well as on success, so it does not settle anything.
|
||||||
|
- `maxOctets` (a public option) bounds reassembly only. The handled-message octet cap is a separate constant, `maxHandledOctets`.
|
||||||
|
|
||||||
|
**One name over several concepts:**
|
||||||
|
- **`idle`** has four meanings:
|
||||||
|
- A count reaching zero: `IdleWaiters`, `SendWindow.idle`, `HandledMessages.idle`.
|
||||||
|
- Stopping the sweep timer: `ExpiringGroups.idle()`.
|
||||||
|
- The idle-timeout timer: `LinkTimers.idle`.
|
||||||
|
- The `idleTimeout` option.
|
||||||
|
- **`settle`** has four meanings: wake all waiters (`IdleWaiters.settle`), wake only if the count is zero (`HandledMessages.settle`), resolve one request (`PendingRequests.settle`), and the local `settle` closures in LinkWaiters, SendWindow and client.
|
||||||
|
- **`link`** means the socket (`SmsInput.link`, `const link = this.session.sock`), the connection's lifetime (`LinkState`, `LinkTimers`, `LinkWaiters`) and which end of the connection this is (`LinkEnd`).
|
||||||
|
|
||||||
|
**Two names for one concept:**
|
||||||
|
- The lost link is spelled `linkLost` (the SessionLife transition and `OutgoingRequests.linkLost`) and `linkDown` (the LifeEffects hook and `Session.linkDown`).
|
||||||
|
- The end of the session is spelled `end()`, the state `ended`, and the effect `over`.
|
||||||
|
- A message whose handler is running is spelled "handled" (the class and its logs), `running` (its field) and "being handled" (README).
|
||||||
|
|
||||||
|
## 4. What I would restructure, ranked
|
||||||
|
|
||||||
|
1. **Group `src/` into about 6 directories:** `codec/` (pdu, framer, refusal, retained-pdu, defs), `message/` (message, message-body, concat, udh, reassembly), `receipts/` (dlr, dlr-merger, sms-id, and receipt building moved out of sms.ts), `session/` (session, session-life, link-timers, pdu-transport, backoff), `requests/` (outgoing/pending/send-window/link-waiters/idle-waiters, incoming/handled-messages/sms), and top level (client, server, index). Today the AGENTS table does the job the directory tree should do.
|
||||||
|
2. **One retry loop.** Have `fromStart` reuse the `SessionLife` loop, or give client.ts's loop a distinct name and log line.
|
||||||
|
3. **Split `session-options.ts`** into `bind-direction.ts` and `option-checks.ts`, and move `SessionEvents` into `session.ts`.
|
||||||
|
4. **Retire the overloaded verbs** (`idle`, `settle`, `linkLost`/`linkDown`) and rename `closing()` to something like `beginDrain()`.
|
||||||
|
5. **Deduplicate the collectors.** `collectReceipt` (`sms.ts:182`) is `collectSent` (`send-sms.ts:274`) without ids.
|
||||||
|
|
||||||
|
**What the structure gets right:**
|
||||||
|
- `SessionLife` is one state value with the transition diagram in its own header (`session-life.ts:7-24`). Effects reach the rest of the session only through `LifeEffects`.
|
||||||
|
- `OutgoingRequests` reads state through a function and never copies it.
|
||||||
|
- Every collaborator takes a narrow options object.
|
||||||
|
- Result types are used throughout, so control flow has no second error channel.
|
||||||
|
- The comments are dense and mostly say why, not what.
|
||||||
|
- The AGENTS table matches the code file for file.
|
||||||
|
|
||||||
|
## 5. The 3am question
|
||||||
|
|
||||||
|
**Symptom:** during a graceful shutdown the session hangs until the shutdown timeout, even though the application already called `sms.sendResp()` on every message.
|
||||||
|
|
||||||
|
**Cold path, about 3 to 5 minutes:**
|
||||||
|
1. `Session.close` (`session.ts:235`) calls `drain` (`session.ts:317`).
|
||||||
|
2. That calls `this.incoming.drain(timeout)` (`incoming-requests.ts:163`).
|
||||||
|
3. That calls `HandledMessages.idle` (`handled-messages.ts:106`).
|
||||||
|
4. The unit is **`HandledMessages.run`** (`handled-messages.ts:120`). A message leaves `running`, which is what releases the drain, only after `onSms`'s promise settles. `sendResp()` records the answer and releases nothing.
|
||||||
|
|
||||||
|
**Likely cause:** the handler is still awaiting something, typically `sms.sendDlr()`. That goes through `handlers.send`, which is `outgoing.request`: it bypasses the closing-state refusal and waits up to `responseTimeout` (30 s), which is longer than `shutdownTimeout` (5 s).
|
||||||
|
|
||||||
|
**What gets you there:** the README (lines 107-109) and the `OnSms` doc (`session-options.ts:87-92`) both say the handler's promise is the hold. **What slows you down:** the `Sms.sendResp` doc (`sms.ts:47-51`) says nothing about the hold, and the `shutdownTimeout` comment wrongly claims it only bounds requests.
|
||||||
|
|
||||||
|
**Where it rots first:** the wiring in `Session`'s constructor and `transportFor`/`lifeFor`/`incomingFor` (`session.ts:105-294`).
|
||||||
|
- Eight collaborators are cross-wired through closures that capture `this.life` and `this.transport` before those are assigned. That is safe today only because of construction order.
|
||||||
|
- `IncomingRequests` reaches back into `Session` for `sock`, `close()`, `emit`, `bindAllows`, `boundAs` and `linkEnd`, so the dependency runs in both directions.
|
||||||
|
- Every lifecycle feature touches three files: session-life (transition), session.ts (effect) and the collaborator.
|
||||||
|
|
||||||
|
**Where the next two features land:**
|
||||||
|
1. **The goal-9 store** lands under `ExpiringGroups`, which has three owners: `DlrMerger`, `Reassembler` and `HandledMessages`. Each wraps it differently (weigh-evicts, a "spent" set, sweep callbacks), so a persistent seam would have to be cut three times or `ExpiringGroups` would have to become the store interface.
|
||||||
|
2. **A per-PDU rate-limit hook** lands in `OutgoingRequests.sendOnce` (`outgoing-requests.ts:116`), between the window acquire and `attempt`, and must respect the rule that a written request is never retried. The code states that rule, so the change is contained. An alphabet hook would be far worse: `EncodingName` is a closed union that ripples through defs/encodings, message.ts and send-sms.ts.
|
||||||
|
|
||||||
|
## 6. Hardest places, ranked
|
||||||
|
|
||||||
|
1. `session.ts:105-294`, `Session` constructor plus `transportFor`/`lifeFor`/`incomingFor`: closure wiring, and initialisation order matters.
|
||||||
|
2. `session-life.ts:175`, `SessionLife.attempt`: re-enters after two awaits and guards with the `links` counter plus `is('connected')`. It is correct, but you have to hold the whole diagram to read it.
|
||||||
|
3. `session.ts:218`, `Session.unbind`: the `wasOpen`/`closedOnUnbind` arithmetic and the order of error precedence.
|
||||||
|
4. `outgoing-requests.ts:97-183`, `request`/`sendOnce`/`attempt`: a `for(;;)` retry keyed on `written` and a fresh `state()` read.
|
||||||
|
5. `handled-messages.ts:62,120`, `refuses()` (a query that mutates the hysteresis flag and sweeps) and `run()` (answer after settle, then conditional delete).
|
||||||
|
6. `pdu.ts:84-136`, `resolveShortMessage`/`resolveBody`: which field's encoding sets `data_coding`.
|
||||||
|
7. `reassembly.ts:187` `trim` with `expiring-groups.ts:70` `weigh`: eviction can take the current key, with off-by-one accounting of lost parts.
|
||||||
|
8. `client.ts:240-309`, `retryUntilBound`/`keepTrying`: the second loop and its misnamed settle callback.
|
||||||
|
|
||||||
|
**The unit I would least want to modify** is `SessionLife.enter` (`session-life.ts:115`) together with its effect bindings in `Session.lifeFor` (`session.ts:260`). One transition's meaning is split across two files and five callbacks.
|
||||||
|
|
||||||
|
**Intrinsic difficulty is high**, and none of the scores below credit it: an async protocol with reconnect, drain, correlation, reassembly and a byte-exact codec against peers that do not follow the spec.
|
||||||
|
|
||||||
|
## 7. Scores
|
||||||
|
|
||||||
|
- **Navigation 7:** at the "Predictable" anchor. The AGENTS file table plus accurate file names got me from the 3am symptom to `HandledMessages.run` in minutes. It sits no higher because the 37-file flat `src/` depends on that table, and two parallel retry loops share one log line.
|
||||||
|
- **Locality 6:** between "Honest middle" and "Predictable". `LifeEffects` and the `state()` accessor are real seams. Holding it down: `IncomingRequests` reaches back into `Session`, the constructor wiring depends on order, and one lifecycle change spans session-life, session and a collaborator.
|
||||||
|
- **Shape 6:** between 5 and 7. Nesting below the top level is bounded (8 at most per level). Holding it down: the 37-entry top level, a misnamed `session-options.ts`, a mutating `closing()`, and `idle`/`settle`/`link` each covering several concepts.
|
||||||
|
- **Self-sufficiency 7:** at "Predictable". The state diagram, invariant comments and "why" comments sit at the code. Holding it back from higher: the false `shutdownTimeout` comment (`session-options.ts:117`), and `Sms.sendResp` not saying it does not release the drain.
|
||||||
|
- **Overall 6:** capped at 7 by the lowest dimension plus one. The hard parts are marked but spread across the session wiring, and the top level needs a document to map it.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=7 overall=6
|
||||||
|
|
||||||
|
## Draft F, junior seat
|
||||||
|
|
||||||
|
**Junior A: comprehension report for draft-f**
|
||||||
|
|
||||||
|
I read README.md, AGENTS.md and every file under `src/`, all in draft-f. I opened no test.
|
||||||
|
|
||||||
|
## 1. Hardest places, hardest first
|
||||||
|
|
||||||
|
1. **`src/expiring-groups.ts:38-54, 111`: the `ExpiringGroups` getters `full`, `size` and `weight`, and `get()`, which all call `expire()`.**
|
||||||
|
- Reading a property has side effects. It can fire `onDrop`.
|
||||||
|
- In `Reassembler`, `onDrop` becomes `onLost`, then `port.report`, then a `sessionError` emit. In `RunningHandlers` it logs a warn and settles the drain's waiters.
|
||||||
|
- So `this.running.size` in `IncomingRequests.refusedAtBound` (`incoming-requests.ts:224`) can emit events on the application's emitter. I only found this by following `onDrop` through three classes.
|
||||||
|
- No comment at the call sites says so. It stayed a trap even after I understood it.
|
||||||
|
|
||||||
|
2. **`src/reassembly.ts:113` (`Reassembler.collect`) with `weighed()` at `:180`, `dropped()` at `:194` and the `weighing` field at `:91`.**
|
||||||
|
- `weighing` is a side channel. It is set around a `weigh()` call so that the drop callback, which fires synchronously inside it, can subtract the newest segment from the reported loss.
|
||||||
|
- `weighed()` then reads `get(key)` again to find out whether its own group was evicted.
|
||||||
|
- To follow it I had to hold several things at once:
|
||||||
|
- part and total validation;
|
||||||
|
- a group that is new or already there;
|
||||||
|
- eviction by count inside `set`;
|
||||||
|
- eviction by weight inside `weigh`;
|
||||||
|
- the "newest segment stays with the peer" rule.
|
||||||
|
- The field's comment explains why but not how. It was resolved only after a second read.
|
||||||
|
|
||||||
|
3. **`src/incoming-requests.ts:260` (`onMessage`) and `:292` (`handOver`), together with `sms.ts:126` (`sendResp`) and `:196` (`sendDlr`).**
|
||||||
|
- A message's answer is spread over four places:
|
||||||
|
- answered on arrival for segments;
|
||||||
|
- the handler's return;
|
||||||
|
- an early `sendResp()`;
|
||||||
|
- the refusal on a throw, which is itself split by `answeredOnArrival`.
|
||||||
|
- The shared state is the mutable `answer` object captured in the closures of `createSms` (`sms.ts:80`).
|
||||||
|
- `throttledStatus` versus `refusedSegmentStatus` needs the `carriedAs` / `standsInFor` indirection for `data_sm`.
|
||||||
|
- The README's "Receiving in depth" section resolved it. The code alone did not.
|
||||||
|
|
||||||
|
4. **`src/pdu.ts:84` (`resolveShortMessage`) and `:113` (`resolveBody`).**
|
||||||
|
- `CodingSource` decides which of `short_message` and `message_payload` may rewrite `data_coding`.
|
||||||
|
- There are five return paths, depending on Buffer or string, empty or not, and a string `message_payload`.
|
||||||
|
- `data_coding` is patched onto the params from two places.
|
||||||
|
- The type comment at `:74` helps, but I had to trace each branch by hand. It remained partly opaque, for example why an empty encoded string falls to `message_payload`.
|
||||||
|
|
||||||
|
5. **`src/defs/encodings.ts:152-191`: `messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`.**
|
||||||
|
- Bit masks (`& 0x80`, `>> 2 & 0x03`, `0xF0`) that I had no background for.
|
||||||
|
- Two comments are stacked oddly at `:160-162`: a `//` block and then a `/** */`, both describing the function below.
|
||||||
|
- It stayed opaque without the GSM 03.38 spec. The intrinsic difficulty is high.
|
||||||
|
|
||||||
|
6. **`src/defs/tlvs.ts:110` (`WriteValue`) and `:284` (`keyedTlvs`, with the `isTlvs` guard).**
|
||||||
|
- Conditional types nested three deep, plus a runtime re-validation of what the code just built, because casts are banned.
|
||||||
|
- Hard rule 4 in AGENTS.md explains why the guard exists. The type gymnastics remained costly.
|
||||||
|
|
||||||
|
7. **`src/reconnect-loop.ts:82` (`schedule`), `:127` (`attempt`) and `:64` (`adopt`).**
|
||||||
|
- `upAt` resets the backoff only after a link lasted `maxDelay`.
|
||||||
|
- `links > 0` decides `unref`.
|
||||||
|
- A `stopped` check comes after an `await` in `attempt`.
|
||||||
|
- There are three flags (`timer`, `attempting`, `stopped`) guarding re-entry.
|
||||||
|
- The comments explain each rule, so it was resolved, but it is order-sensitive.
|
||||||
|
|
||||||
|
8. **`src/send-window.ts:42` (`release`).** Handing a slot to a waiter without decrementing `inFlight` is correct but not commented. I had to reason out that the slot transfers.
|
||||||
|
|
||||||
|
## 2. The unit I would least want to modify
|
||||||
|
|
||||||
|
`Reassembler.collect` / `weighed` / `dropped`. A change to eviction order inside `ExpiringGroups.weigh`, or to when `get()` expires, silently changes the loss counts reported to the application, which are sessionError events. Nothing at the call site tells me that the coupling exists.
|
||||||
|
|
||||||
|
## 3. Expected hard, found easy
|
||||||
|
|
||||||
|
- **`PduFramer`:** short, one clear purpose.
|
||||||
|
- **`PendingRequests` and `OutgoingRequests`:** the split between `LinkLostError` ("never written, retry") and `UnansweredError` ("may have been taken") is named well. `SmppClient.send`'s retry loop (`client.ts:143`) read at once.
|
||||||
|
- **The layering of Session, IncomingRequests and SessionPort:** the port type (`incoming-requests.ts:50`) says exactly what the collaborator may touch.
|
||||||
|
- **`server.ts` `handleRequest`:** bind-before-anything is compact.
|
||||||
|
- **`defs/types.ts`:** long but repetitive and uniform.
|
||||||
|
|
||||||
|
## 4. Prose debt
|
||||||
|
|
||||||
|
**What I needed, and what it cost to find:**
|
||||||
|
|
||||||
|
- **README "Glossary":** needed for ESME/SMSC, `esm_class`, `data_coding`, UDH and `sar_*`. It sits about 600 lines down the README and nothing in `src/` points at it.
|
||||||
|
- **README "Receiving in depth" and "Server in depth":** needed to understand the answer-on-arrival model before `onMessage` made sense.
|
||||||
|
- **AGENTS "GSM 7-bit is sent unpacked":** needed to believe the 153/134 budget in `message.ts` (the `segmentUnits` constant near the top).
|
||||||
|
- **The `ASCII` encoding name:** it means GSM 03.38. Only the comment in `encodings.ts` near line 45 and a README table say so. The name lies.
|
||||||
|
- **Bit layout of `esm_class` and `data_coding`:** not documented anywhere I was allowed to read. I would need the spec.
|
||||||
|
|
||||||
|
**Prose that told me nothing the code did not:**
|
||||||
|
|
||||||
|
- AGENTS' architecture map repeats most file-level doc comments almost word for word. It was still useful as an index.
|
||||||
|
- Several Conventions paragraphs about tests (`dummy-smsc`, `recordingDeps`) were irrelevant to reading `src/`.
|
||||||
|
- AGENTS' decision index names `ReconnectOptions`, which does not exist in `src/`. The type is `ReconnectTuning`. That line is stale.
|
||||||
|
- Comments that add nothing:
|
||||||
|
- `'Whether the socket has closed.'` on `closed` (`session.ts:148`);
|
||||||
|
- `'Sends a request and resolves with the peer's response.'` (`session.ts:172`);
|
||||||
|
- `'Connects to an SMSC and binds.'` (`client.ts:414`).
|
||||||
|
|
||||||
|
## 5. Scores
|
||||||
|
|
||||||
|
| Dimension | Score | Anchor and cause |
|
||||||
|
|---|---|---|
|
||||||
|
| Navigation | 7 | Predictable. AGENTS' one-line-per-file map and honest file names (`pdu-framer`, `dlr-merger`, `send-window`) took me from a symptom to a file first time. `ASCII` meaning GSM and the three meanings of "refuse" and "answer" (`Session.refuse`, `IncomingRequests.refuse`, `sendReturn` / `answer` / `sendResp`) keep it off 8. |
|
||||||
|
| Locality | 5 | Honest middle. The getters in `ExpiringGroups` fire callbacks that emit on the session. The `weighing` side-channel field depends on synchronous re-entry. The mutable `answer` closure is shared by `sendResp` and `sendDlr`. Changing one piece means holding its callback chain. |
|
||||||
|
| Shape | 6 | Between honest middle and predictable. Fan-out is bounded (Session builds four collaborators; IncomingRequests builds two). Some names mislead: `ASCII`; `stopping`, which the peer's unbind also sets; `over` versus `closed`; `SessionListener`'s emit guard copied three times. |
|
||||||
|
| Self-sufficiency | 6 | Between honest middle and predictable. Inline spec citations ("SMPP 3.4 5.3.2.26", "4.6.2") and why-comments mostly carry it. The answer-on-arrival model and the data_coding bit groups still need the README or the spec open beside the code. |
|
||||||
|
| Overall | 6 | Capped by Locality (5 + 1). |
|
||||||
|
|
||||||
|
**Intrinsic difficulty:** the problem is hard, and gets no bonus in these scores. It involves concurrency, the protocol's split between UDH and `sar_*`, receipts that look like messages, and the GSM alphabets.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=5 shape=6 self=6 overall=6
|
||||||
|
|
||||||
|
## Draft F, mid seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked hardest first**
|
||||||
|
|
||||||
|
- **`src/reassembly.ts:330` `Reassembler.weighed()` / `dropped()` (:344), together with `src/expiring-groups.ts:38-54` (the `full`, `size` and `weight` getters).** `weighed()` sets `this.weighing` so that `dropped()`, reached re-entrantly through `ExpiringGroups.weigh()` → `dropOldest()` → `onDrop`, knows to subtract the newest segment from the loss count. That is a side channel through a field. On top of it, every getter on `ExpiringGroups` runs `expire()`, which fires `onDrop`. So reading `size` in a log line (`reassembly.ts:358`, `this.groups.weight` inside `lost()`) can drop other groups and report them mid-report. The comments at :240 and :274 got me to what it intends. Whether the re-entrant drops are harmless stayed unresolved.
|
||||||
|
- **`src/session.ts:211-332` `Session.unbind()` / `drain()` / `finish()` / `end()`.** Three flags carry a lifecycle: `over`, `stopping` and the `ended` promise. `closed` is a getter over `over`, and `port().end` (:262) sets `stopping` from outside the drain. `unbind()`'s `droppedOnUnbind` depends on `UnansweredError` and `this.over` agreeing after an await. The field comments (:64, :66) and the class doc resolved which flag means what, but only after I built a table by hand.
|
||||||
|
- **`src/client.ts:515` `SmppClient.send()`.** It loops on `LinkLostError`. Whether it terminates depends on `ReconnectLoop.bound()` (which hands back the current session until the socket's `close` event), `Session.send()` (which checks `over`, set only on `close`) and `PduTransport.write()` (which fails on `sock.destroyed`). A socket that is destroyed but has not yet emitted `close` looks to me like it gives a loop that resolves only through microtasks and may never yield. I could not rule that out without a test. This is the plainest action-at-a-distance in the codebase, and it stayed opaque.
|
||||||
|
- **`src/incoming-requests.ts:260-351` `onMessage()` → `handOver()` → `refuse()`, with `src/sms.ts:796-860` `createSms()` / `sendResp()`.** I had to hold several things at once:
|
||||||
|
- whether the message was answered on arrival;
|
||||||
|
- whether the handler threw;
|
||||||
|
- whether it returned `{ smsId }` or `{ status }`;
|
||||||
|
- the `Answer` object mutated inside the closure;
|
||||||
|
- `carriedAs` choosing the retry status.
|
||||||
|
|
||||||
|
`alreadyAnswered` has four error texts for these combinations. The README section "Receiving in depth" resolved it; the code alone did not.
|
||||||
|
- **`src/defs/encodings.ts:152-191` `messageClassOf()`, `messageClassEncoding()`, `encodingByDataCoding()`.** This is bit arithmetic on a GSM 03.38 layout I have never seen. There is a `//` comment and then a `/** */` comment stacked on one function (:160-162), and the `//` one reads as though it belongs to the function above. The comments give the bit positions. Why 0x03 means one thing below 0x80 and another in a class group stayed half opaque.
|
||||||
|
- **`src/dlr.ts:157-238` `messageType()` / `receiptStatus()` / `dlrFromPdu()`.** `'unmarked'` versus `'receipt'`, whether the TLV or the body wins, and `statusMsg` possibly being `undefined` before it falls back to `'UNKNOWN'`. The README's Delivery receipts section resolved it. Without the README I would have guessed wrong about `'other'`.
|
||||||
|
- **`src/pdu.ts:323-375` `resolveShortMessage()` / `resolveBody()`.** The `CodingSource` naming feels inverted: an empty `short_message` yields `source: 'message_payload'`, meaning "`message_payload` may set `data_coding`". The comment at :313 explains it, but I had to read it twice.
|
||||||
|
- **`src/reconnect-loop.ts:83` `schedule()`.** It resets the backoff only if `upAt` shows the link outlasted `maxDelay`, clears `upAt`, doubles the delay after capturing it, and calls `unref()` only once a link has existed. Each step has a comment, which resolved it. It is dense rather than opaque.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify: `ExpiringGroups` (`src/expiring-groups.ts`).** Three owners (`Reassembler`, `DlrMerger` with its `spent` store, and `RunningHandlers`) depend on when drops happen and in what order. Reads mutate state and fire callbacks. `set()` can evict. `weigh()` can evict the entry being weighed. Any change moves loss accounting, drain wake-ups and receipt-merge refusal all at once. Note also that `RunningHandlers` logs "giving up on a handler that never returned" for an *eviction*, not only an expiry.
|
||||||
|
|
||||||
|
3. **Expected hard, found easy**
|
||||||
|
- The wire codec: `defs/types.ts`, `pdu.ts` parse/build, TLV read/write. It is long but regular: every reader range-checks, and every error names the parameter.
|
||||||
|
- `PduFramer`.
|
||||||
|
- `PendingRequests` and `SendWindow`.
|
||||||
|
- The server's bind handling (`handleRequest`).
|
||||||
|
- The option validation in `session-options.ts`.
|
||||||
|
|
||||||
|
Result-typed code with no throws made control flow easy to follow.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **Needed:**
|
||||||
|
- the README Glossary for ESME, SMSC, `esm_class`, `data_coding`, UDH and `sar_*` (cheap to find, essential for me);
|
||||||
|
- the README "Receiving in depth" and "Server in depth" sections, for the answer rules in `sms.ts` and `incoming-requests.ts` (about 10 minutes to locate);
|
||||||
|
- the AGENTS "GSM 7-bit is sent unpacked" section, for `segmentUnits` 153 versus 134.
|
||||||
|
|
||||||
|
The AGENTS decision index names choices, such as "A message id base is merged at most once", whose reasoning lives in `docs/decisions.md`, which I was not allowed to open. For a few (the `spent` store and `LinkLostError` retry), the title alone left me unsure whether the behaviour I saw was the intent. The charter also says `IncomingRequests` and `Sms` get "named functions, never the session itself". That is false as written: `SessionPort.session` and `Sms.session` both hand over the full `Session` (`incoming-requests.ts:65`, `:293`).
|
||||||
|
- **Told me nothing:**
|
||||||
|
- most AGENTS architecture one-liners, which restate the file names;
|
||||||
|
- the seven `declare` listener lines, repeated in all three emitters;
|
||||||
|
- `/** Whether the socket has closed. */` on `closed`;
|
||||||
|
- `defaults.ts`'s "README's option tables restate the public ones";
|
||||||
|
- many decision-index bullets that simply restate what the code shows, such as "`reconnect` takes `{ minDelay, maxDelay }`…".
|
||||||
|
|
||||||
|
Intrinsic difficulty: moderate to high. Two alphabets' bit layouts, two concatenation spellings, and receipts sharing a command with messages are the problem's own difficulty, not the code's. They get no bonus.
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
- **Navigation: 7.** At the "predictable" anchor: `src/` is flat, file names follow concepts (`dlr-merger`, `pdu-framer`, `send-window`), and the AGENTS map matched the tree exactly. It stops short of 8 because of placements like `leftOf` in `idle-waiters.ts` and `refusedSegmentStatus` exported from `incoming-requests.ts`.
|
||||||
|
- **Locality: 5.** At the "honest middle": getters with side effects in `ExpiringGroups`, the `weighing` side channel in `Reassembler`, and `SmppClient.send()` being correct only across the timing of three modules are all state changed out of sight.
|
||||||
|
- **Shape: 6.** Between 5 and 7. Files are small and fan-out is bounded, but the vocabulary is overloaded:
|
||||||
|
- answering: `answer`, `sendReturn`, `sendResp`;
|
||||||
|
- refusing: `refuse` in two classes with different meanings, plus `refusedAtBound`;
|
||||||
|
- ending: `over`, `closed`, `stopping`, `end`, `finish`, `ended`;
|
||||||
|
- and `SessionPort` claims a narrow seam while carrying the whole `Session`.
|
||||||
|
- **Self-sufficiency: 6.** Between 5 and 7. Comments cite SMPP sections and state the why in place (for example `respIdParams` and `refusalStatus`). But the answer rules and receipt classification needed README sections open beside them, and the decision index points at reasoning that is not in the code.
|
||||||
|
- **Overall: 6.** Capped by locality (5 + 1). The code reads cleanly line by line; what costs is the few places where state moves out of sight.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=5 shape=6 self=6 overall=6
|
||||||
|
|
||||||
|
## Draft F, senior seat
|
||||||
|
|
||||||
|
1. **Hardest places, ranked**
|
||||||
|
|
||||||
|
1. **`src/incoming-requests.ts:260-351`, `IncomingRequests.onMessage` / `handOver` / `refuse`, plus `refusedAtBound` at :224.** Before I could predict the answer to one inbound PDU, I had to hold eight branches at once:
|
||||||
|
- the running-handler bound;
|
||||||
|
- concatenated or whole;
|
||||||
|
- kept or refused, and full or unplaceable, with the refusal status differing between sar and UDH;
|
||||||
|
- whole or partial;
|
||||||
|
- link already closed;
|
||||||
|
- no `onSms`;
|
||||||
|
- the handler threw or returned;
|
||||||
|
- answered on arrival or not.
|
||||||
|
|
||||||
|
"Who answers the peer, and when" is spread over `onMessage`, `handOver`, `refuse`, `createSms(answeredAs)` and `sms.sendResp`/`alreadyAnswered` in another file (`sms.ts:106-144`). `refusedAtBound` returns a boolean but also writes the answer and flips the `refusing` flag. The comment at :256 and README "Receiving in depth" / "Server in depth" resolved it, but only after two passes.
|
||||||
|
2. **`src/reassembly.ts:91,113-137,180-198`, `Reassembler.collect` / `weighed` / `dropped`.** The `weighing` field is a side channel. It is set around `groups.weigh()` so that the synchronous `onDrop` callback, which re-enters `dropped()`, can subtract the newest segment from the reported loss. `collect` also inserts the segment into `group.parts` before it knows whether the group survives the weighing. The comments at :90 and :124 made it resolvable, but only by tracing the re-entrancy by hand.
|
||||||
|
3. **`src/expiring-groups.ts:250-266,295-306,323-334`, the `ExpiringGroups` getters `full` / `size` / `weight` and `weigh`.** Reading a property runs `expire()`, which fires `onDrop` callbacks. Through `RunningHandlers` (`running-handlers.ts:259`) that means a plain read of `this.running.size` inside a log call in `refusedAtBound` (`incoming-requests.ts:229`) can log a "giving up on a handler" warning and wake a drain. The class comment says "the expired go on every access". Nothing at the call sites marks it. This stayed partly opaque: I am not certain every caller tolerates it.
|
||||||
|
4. **`src/session.ts:211-221,291-332`, `Session.unbind` / `drain` / `finish` / `end`.**
|
||||||
|
- There are three lifecycle flags: `over`, `stopping` and `bind`.
|
||||||
|
- `stopping` is also set from outside, through `SessionPort.end` (:262).
|
||||||
|
- `drain` reads the same state as `this.over` at :294 and as `this.closed` at :303.
|
||||||
|
- `unbind` deliberately bypasses `send()`'s stopping check by calling `outgoing.request` directly, uncommented.
|
||||||
|
- `droppedOnUnbind` needed its docblock plus the charter's decision index ("a close arriving after our own unbind is clean") before I trusted it.
|
||||||
|
5. **`src/client.ts:143-156,202-217,415-438` with `src/reconnect-loop.ts:64-153`, `SmppClient.send` retry loop / `takeFirst` / `keepTrying` / `ReconnectLoop`.** The `LinkLostError` contract spans four files. `send-window.close` sets it for queued requests and `OutgoingRequests.attempt` sets it on a write failure (`outgoing-requests.ts:269,287`). `SmppClient.send` consumes it, with `ReconnectLoop.bound`/`release` in between. The first session is opened outside the loop and then `adopt`ed. `links === 1` means "first", and `links > 0` decides `unref()`. Resolved by the `LinkLostError` class doc and the README's "Sends and the link".
|
||||||
|
6. **`src/pdu.ts:74-136`, `resolveShortMessage` / `resolveBody`.** The rule for which of `short_message` and `message_payload` may overwrite `data_coding` uses `CodingSource`. An empty Buffer counts as `message_payload`, and only a non-empty encoded `short_message` rewrites the coding. I read it three times. The `CodingSource` doc resolved it.
|
||||||
|
7. **`src/defs/encodings.ts:476-515`, `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.** This is bit arithmetic over GSM 03.38 coding groups that I had no background in. A `//` comment and a `/** */` for different concerns are stacked on one function (:484-486). The comments carry the rule, so it was domain cost rather than code cost.
|
||||||
|
8. **`src/defs/tlvs.ts:103-126,284-306`, the `WriteValue` / `Repeated` conditional types and `keyedTlvs` → `isTlvs`.** The type layer takes a slow read. The runtime re-validation that ends in "a defect in this library" exists only to avoid a cast. I understood why only through hard rule 4 in the charter.
|
||||||
|
|
||||||
|
2. **The unit I would least want to modify:** `IncomingRequests.onMessage`/`handOver` together with `sms.ts` `sendResp`/`alreadyAnswered`. The invariant "every PDU gets exactly one answer, and no answer names an id or refusal after the segments were answered" is not stated in one place. It is enforced jointly by the `answeredAs` argument, the mutable `answer` object captured in `createSms` closures, the `closed()` check placed after `createSms`, and `refuse`'s `answeredOnArrival` branch. A change to any one of these can double-answer or silently drop, and only a test would tell me.
|
||||||
|
|
||||||
|
3. **Expected to be hard, found easy:**
|
||||||
|
- The codec: `pdu.ts` read/write, `defs/types.ts` wire types, `pdu-framer.ts`. It is long but uniform and bounds-checked the same way everywhere.
|
||||||
|
- `PendingRequests`, `SendWindow` and `IdleWaiters`: small, one job each.
|
||||||
|
- `dlr.ts` receipt parsing, with the operator quirks commented inline.
|
||||||
|
- `send-sms.ts`: a linear checklist, then fan-out.
|
||||||
|
- The server bind composition in `server.ts:508-533`.
|
||||||
|
|
||||||
|
4. **Prose debt**
|
||||||
|
- **Needed:**
|
||||||
|
- README "Receiving in depth" and "Server in depth", for answered-on-arrival semantics and the retry statuses. Found by the table of contents, so the cost was low.
|
||||||
|
- The charter's architecture map. It was the most valuable document: every file is listed with a truthful one-liner.
|
||||||
|
- The decision index titles. They told me that behaviours like the unbind-close and five-minute handler were deliberate. With `docs/decisions.md` off-limits, a title was sometimes all I had.
|
||||||
|
- I opened no tests.
|
||||||
|
- **Told me nothing new:**
|
||||||
|
- The AGENTS.md "Defects found in 0.4.0" table (history, irrelevant to reading `src/`).
|
||||||
|
- The long test-fixture convention paragraphs.
|
||||||
|
- README "Methods", which lists names already typed.
|
||||||
|
- Comments that restate code:
|
||||||
|
- `session.ts:148` "Whether the socket has closed."
|
||||||
|
- `session.ts:172` "Sends a request and resolves with the peer's response."
|
||||||
|
- `server.ts:631` "Starts listening… Resolves once the socket is bound."
|
||||||
|
- `tlvs.ts:14` "Ordered by tag id", which repeats the charter.
|
||||||
|
- The seven `declare` listener lines, copied across three emitters, are boilerplate rather than prose, but they are reading cost all the same.
|
||||||
|
|
||||||
|
5. **Scores**
|
||||||
|
- **Navigation 8:** between "predictable" and "near duress-proof". AGENTS.md's file map matches `src/` one-to-one, and names like `pdu-refusal.ts`, `send-window.ts` and `dlr-merger.ts` lead from a symptom to the file first try. The detour is the answer path, split across `incoming-requests.ts` and `sms.ts`.
|
||||||
|
- **Locality 6:** between "honest middle" and "predictable". The seams are named and narrow (`SessionPort`, `SmsDeps`, `SendSmsDeps`, `LinkLostError`). Three things still break locality:
|
||||||
|
- `ExpiringGroups` getters fire callbacks on read.
|
||||||
|
- `Reassembler.weighing` is a re-entrancy side channel.
|
||||||
|
- `Session`'s flag trio is mutated through `port.end`.
|
||||||
|
- **Shape 7:** "predictable". No file is past about 440 lines and fan-out per level is small. A few names mislead:
|
||||||
|
- `ExpiringGroups` is used for running handlers and spent ids, which are not groups.
|
||||||
|
- `closed` and `over` are two names for one state.
|
||||||
|
- `refusedAtBound` answers the peer as a side effect.
|
||||||
|
- `ReconnectLoop.attempt`'s comment calls the library's own connect and bind "the application's".
|
||||||
|
- **Self-sufficiency 7:** "predictable". Comments carry the SMPP section and the why at the non-obvious points (`sms-id.ts:623`, `message.ts:13`, `dlr.ts:247-253`, `pdu-refusal.ts:446`). The answered-on-arrival contract is the one thing that needed the README open beside the code.
|
||||||
|
- **Overall 7:** capped at 7 by locality. A cold senior is productive within a week and knows which corners to fear. Intrinsic difficulty is moderately high (protocol quirks plus a concurrent drain and reconnect), and that earns no bonus.
|
||||||
|
|
||||||
|
SCORES nav=8 loc=6 shape=7 self=7 overall=7
|
||||||
|
|
||||||
|
## Draft F, architect seat
|
||||||
|
|
||||||
|
**Architect, inherited: comprehension report on @larvit/smpp (draft-f)**
|
||||||
|
|
||||||
|
## 1. Map from README and tree only (verbatim, written before opening any source)
|
||||||
|
|
||||||
|
Top-level areas I expect, although `src/` is flat and shows none of them:
|
||||||
|
1. **Public handles**: `index.ts`, `client.ts` (SmppClient, reconnecting), `server.ts` (listener, one Session per connection), `session.ts` (one socket's life), `sms.ts` (the inbound `sms` handle with sendResp/sendDlr).
|
||||||
|
2. **Session machinery**: `link-timers` (enquire_link and idle), `reconnect-loop` (backoff), `send-window` (maxOutstanding), `pending-requests` (seqNr correlation and timeout), `outgoing-requests` (window plus pending), `incoming-requests` (dispatch of peer requests), `running-handlers` (onSms handlers in flight, "Handlers still running" in the README), `idle-waiters` (maybe the idle timeout?), `pdu-transport` and `pdu-framer` (socket to PDUs).
|
||||||
|
3. **Codec**: `pdu.ts`, `pdu-refusal` (PduRefusedError), `retained-pdu` (a PDU kept for retry?), and `defs/` for the spec tables and wire types.
|
||||||
|
4. **Message content**: `message.ts` (encode, split, bitCount, smppTime), `message-body` (short_message vs message_payload), `concat` and `udh` (probably the reading and writing of concatenation), `reassembly`, `expiring-groups` (the reassembly store?), `send-sms` (submit composition).
|
||||||
|
5. **Receipts**: `dlr.ts`, `dlr-merger` (messageDlr), `sms-id` (notations, `<base>-<n>`).
|
||||||
|
6. **Plumbing**: `result`, `log`, `error-from`, `uuid`, `defaults`, `session-options`, `unanswered-error` (went out, no answer), `link-lost-error` (never reached the socket).
|
||||||
|
|
||||||
|
Names that do not give their purpose: `idle-waiters`, `retained-pdu`, `expiring-groups`, `error-from`, and `defaults` vs `session-options`.
|
||||||
|
|
||||||
|
What the README led me to expect: a store interface (goal 9). The README itself says it has not shipped, so its absence is fine.
|
||||||
|
|
||||||
|
## Where the map was wrong, and what each correction cost
|
||||||
|
|
||||||
|
- **`idle-waiters`** is a primitive that waits for a count to fall to zero. It is not the idle timeout, and it also exports `leftOf()`, the deadline arithmetic `client.ts` uses. Cost: low. The misleading part is `leftOf` living there.
|
||||||
|
- **`retained-pdu`** copies a PDU off the wire so holding it does not pin the chunk, and weighs what holding it costs. It has nothing to do with retry. Cost: low. The copy invariant is split with `defs/tlvs.ts:250`, which already copies TLVs, so `retained-pdu.ts:5`'s claim that "wire reads hand back views" is only half true.
|
||||||
|
- **`expiring-groups`** is a generic capped, weighed, expiring map. Besides reassembly it also backs `DlrMerger` (twice) and, surprisingly, `RunningHandlers` as a counter (`ExpiringGroups<true>` keyed by serial). Cost: medium. "Groups" misleads for the handler counter.
|
||||||
|
- **`concat` / `udh`**: splitting is in `message.ts`. `udh.ts` holds the outgoing `ConcatReference` counter plus the parse `concatInfo`. `concat.ts` chooses between UDH and `sar_*`. Cost: medium. It took three files to place the concatenation concepts.
|
||||||
|
- **`session-options`** is not only options. It also holds bind-direction policy (`bindCarries`, `standsInFor`), `SessionEvents`, the hook types, and validation for client- and server-only options (`authenticate`, `connectTimeout`, `reconnect`, `fromStart`). Cost: medium. I would never have looked there for "which way does a data_sm travel".
|
||||||
|
- **`pdu.ts` depends on `message.ts`** (`encodeBody`, `decodeMessage`), which depends on `udh.ts`. So the codec sits above the message layer, not just above `defs/`. Cost: low, but the layering in the AGENTS text is incomplete.
|
||||||
|
- The rest of the map held.
|
||||||
|
|
||||||
|
## Fan-out, level by level
|
||||||
|
|
||||||
|
- **L0, the repo:** `src`, `test`, `docs`, `benchmarks`, `interop-tests`, plus about 8 top-level `.md` files. Fine.
|
||||||
|
- **L1, `src/`:** 36 files plus `defs/`, so 37 entries. **This is the worst level.** About six real areas exist, but the layout shows none of them. The only map is the Architecture block in AGENTS.md.
|
||||||
|
- **L2, `defs/`:** 7 files, clean.
|
||||||
|
- **L3, the big units:**
|
||||||
|
- `Session` composes 4 collaborators plus a hand-built `SessionPort` of 12 members.
|
||||||
|
- `IncomingRequests` holds `Reassembler`, `RunningHandlers`, `createSms`, the port and the hooks, and imports 23 symbols.
|
||||||
|
- `SmppClient` holds `ReconnectLoop`, `DlrMerger` and `ConcatReference`, plus about 200 lines of free connect and bind functions.
|
||||||
|
|
||||||
|
## Names
|
||||||
|
|
||||||
|
**Names that mislead**
|
||||||
|
- `IncomingRequests.drain` (`incoming-requests.ts:147-153`) calls the count of running handlers `unanswered` and reports "Shut down with N message(s) unanswered". A handler that already called `sendResp()` is still counted. That is the 3am bug's own error text pointing the operator at the wrong thing.
|
||||||
|
- `RunningHandlers`' `onDrop` logs "giving up on a handler that never returned" for any drop, including an `evicted` one. Eviction can only be avoided because `refusedAtBound` is checked first, somewhere else (`running-handlers.ts:28`).
|
||||||
|
- `session-options.ts`, as above.
|
||||||
|
- `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`.
|
||||||
|
- `ExpiringGroups` used as a counter.
|
||||||
|
|
||||||
|
**One name over several concepts**
|
||||||
|
- **refuse:** `Session.refuse` (codec-refused PDU), `IncomingRequests.refuse` (application did not take the message), `refusedAtBound`, `Refusal` (a reassembly slot), `refusedSegmentStatus`, `refusalAnswer`, `PduRefusedError`.
|
||||||
|
- **end:** `Session.end`, `OutgoingRequests.end` and `IncomingRequests.end` all mean "the socket is gone". `SessionPort.end` means "the peer unbound, destroy the socket". `SmppClient.end` means "the client is over".
|
||||||
|
- **answer:** `SessionPort.answer` writes a response. `sms.ts`'s `Answer` is mutable answered-state. `answerOf` converts the handler's return.
|
||||||
|
- **closed:** a public getter on `Session`, a private field on `SmppClient`, and a port function.
|
||||||
|
|
||||||
|
**One concept with two names**
|
||||||
|
- Session lifecycle: `over` / `closed`, and `stopping` / "shutting down".
|
||||||
|
- The UDH indicator is checked through `hasUdh` in 5 places, and the body is sometimes `params.short_message` (a string, or a Buffer when a UDH is present) and sometimes `shortMessageOctets`.
|
||||||
|
- The submit bind check is spelled twice: `client.ts:159` reads `options.bindType`, `session.ts:195` calls `bindAllows`.
|
||||||
|
|
||||||
|
## Restructure, ranked
|
||||||
|
|
||||||
|
1. **Split `IncomingRequests`.** Routing (`route`, `unhandled`, `onDelivery`) is one piece. Message intake (bound refusal, reassembly, answer-on-arrival, `handOver`, `refuse`) is a second, called something like `MessageIntake`. It is the densest unit and the one that grows.
|
||||||
|
2. **Carve bind-direction policy out of `session-options.ts`** into `bind-direction.ts`, and move client/server option validation next to its owners or into an `option-checks.ts`.
|
||||||
|
3. **Make the "drain waits on" counter say what it counts.** Either release it on `sendResp()` plus pending receipts, or rename the error to "handlers still running". Also stop using `ExpiringGroups` as the handler counter.
|
||||||
|
4. **Put concatenation in one module:** `ConcatReference`, `concatInfo`, `concatOf`, `udhLength`, and the UDH build in `splitMessage`.
|
||||||
|
5. **Group `src/` into 4–5 folders:** handles, link, codec, message, receipts. The flat 37 files is the main Shape cost.
|
||||||
|
6. **Extract the emitter guard.** The `emit` override, `captureRejectionSymbol` and 7 `declare` lines are copied three times (Session, SmppClient, SmppServer).
|
||||||
|
|
||||||
|
## What the structure gets right
|
||||||
|
|
||||||
|
- Narrow seams: `SessionPort`, `SmsDeps`, `SendSmsDeps`, and the `ReconnectLoopOptions` callbacks. Collaborators do not reach into the Session.
|
||||||
|
- Every unit is small and single-noun (`SendWindow`, `PendingRequests`, `LinkTimers`, `PduFramer`).
|
||||||
|
- `Result` is used everywhere, so control flow reads top-down.
|
||||||
|
- Comments carry spec sections and the peer quirks behind them (Jasmin, CM.com, Kaleyra).
|
||||||
|
- `defaults.ts` is the single source of numbers.
|
||||||
|
- `LinkLostError` vs `UnansweredError` encodes goal 2 in the type.
|
||||||
|
|
||||||
|
## The 3am question
|
||||||
|
|
||||||
|
**Time to the right unit, cold: about 5–10 minutes, three hops.**
|
||||||
|
- Hop 1: grep "drain" lands in `session.ts:291`, `Session.drain`.
|
||||||
|
- Hop 2: that calls `this.incoming.drain(timeout, signal)` at `incoming-requests.ts:146`.
|
||||||
|
- Hop 3: that calls `RunningHandlers.idle` (`running-handlers.ts:63`), and I had to find where `start()` and `done()` are called: `IncomingRequests.handOver`, `incoming-requests.ts:311-314`.
|
||||||
|
|
||||||
|
**The answer:** `done()` fires when the `onSms` handler *returns*, not when `sms.sendResp()` is called. `sendResp` (`sms.ts:126`) never touches `RunningHandlers`. So a handler that answers early and keeps working holds the drain until `shutdownTimeout`. That includes a handler awaiting `sendDlr()` to a slow peer: `sendDlr` goes through `port.request`, which bypasses the stopping check, and the outgoing drain then waits on it too.
|
||||||
|
|
||||||
|
- **Is it a bug?** README line 399 documents "Wait … for every onSms handler still running", so it is by design. The `sendResp` docstring ("for a handler that keeps working after the answer") invites exactly this expectation.
|
||||||
|
- **Right file and unit:** `incoming-requests.ts`, `handOver`, together with `running-handlers.ts`.
|
||||||
|
- **What slows the hunt:** the reported error, "message(s) unanswered", is false for this peer and costs an extra detour into `sms.ts`.
|
||||||
|
|
||||||
|
**Where it rots first:** `IncomingRequests.onMessage` / `handOver`. Every new inbound rule lands there (per-PDU rate limiting, the store for half-reassembled messages, new `data_sm` semantics), and each one adds another `port.closed()` check and another answer path.
|
||||||
|
|
||||||
|
**Where the next two features would land**
|
||||||
|
- **Goal 9's store** would land across `Reassembler`, `DlrMerger` and `ExpiringGroups`. `ExpiringGroups` is the obvious seam, but it is shared with the handler counter, which must not be persisted. Separate them first.
|
||||||
|
- **A per-PDU rate limit (goal 7)** would land in `OutgoingRequests.request` beside `SendWindow`. That is a clean place. The inbound side would land in `IncomingRequests` again.
|
||||||
|
|
||||||
|
## Hardest places, ranked
|
||||||
|
|
||||||
|
1. `src/incoming-requests.ts:260-326`, `IncomingRequests.onMessage` / `handOver`. Bound refusal, reassembly, answer-on-arrival, the handler run, and refusal-or-loss are interleaved with `closed()` checks and `sms.sendResp` side effects.
|
||||||
|
2. `src/reassembly.ts:179-198`, `Reassembler.weighed` / `dropped`. The transient `weighing` field is read inside an `onDrop` callback to discount the newest segment. That is action at a distance through a callback.
|
||||||
|
3. `src/session.ts:291-310` with `incoming-requests.ts:146` and `running-handlers.ts:50-75`, `Session.drain`. It orders handlers, then requests, on a shared deadline (`timeout` for the first, `leftOf(deadline)` for the second), then checks `closed`. The meaning of "unanswered" is wrong.
|
||||||
|
4. `src/expiring-groups.ts:111-122`, `ExpiringGroups.expire`. It fires `onDrop` mid-iteration. `RunningHandlers.onDrop` → `settle()` → `size` → `expire()` re-enters it.
|
||||||
|
5. `src/reconnect-loop.ts:64-153` with `client.ts:143-156`, `ReconnectLoop.adopt` / `attempt` / `down` / `stop` and the client `send` retry loop on `LinkLostError`. The state lives in `session`, `timer`, `attempting`, `stopped`, `upAt` and `links`.
|
||||||
|
6. `src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`. The rules for which of `short_message` or `message_payload` owns `data_coding`.
|
||||||
|
7. `src/sms.ts:126-231`, `sendResp` / `sendDlr` over the shared mutable `Answer` object.
|
||||||
|
|
||||||
|
**The unit I would least want to modify:** `IncomingRequests`, `src/incoming-requests.ts:87-358`.
|
||||||
|
|
||||||
|
## Scores
|
||||||
|
|
||||||
|
Intrinsic difficulty, which earns no bonus: moderately high. It is an async request/response protocol with reassembly, reconnect, a drain and two ends of the link.
|
||||||
|
|
||||||
|
- **Navigation 7.** Sits at "Predictable". File names map to concepts well enough that the 3am path took three hops. It is held below 8 by the flat 37-file `src/` and by the drain's "unanswered" error text pointing at `sendResp`.
|
||||||
|
- **Locality 6.** Between 5 and 7. The narrow ports (`SessionPort`, `SmsDeps`) keep collaborators apart. Hidden coupling holds it down: `RunningHandlers` evicting live handlers unless `refusedAtBound` runs first, the reassembler's `weighing` side channel, and the drain's ordering and shared deadline.
|
||||||
|
- **Shape 6.** Between 5 and 7. Units are small and mostly honest. Held down by the flat `src/` with no visible areas, `session-options.ts` as a grab bag, concatenation spread over three files, and the overloaded refuse/end/answer/closed vocabulary.
|
||||||
|
- **Self-sufficiency 7.** Sits at "Predictable". Most units state their invariant and cite the SMPP section at the site (`message.ts:13`, `pdu.ts:166`, `sms-id.ts:58`). Held below 8 because the area map and the import direction exist only in the AGENTS.md architecture block, not in the layout.
|
||||||
|
- **Overall 6.** Capped at 7 by the lowest dimension plus one. It sits at 6 because the unit that grows, `IncomingRequests`, is the hardest one to change safely.
|
||||||
|
|
||||||
|
SCORES nav=7 loc=6 shape=6 self=7 overall=6
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Plan 1: the application developer's mental model as the source tree
|
||||||
|
|
||||||
|
Lens: an application developer thinks in six verbs: *connect* (client), *listen* (server), *send*,
|
||||||
|
*receive*, *get a report*, *shut down*, plus *read PDUs* when they go low-level. Every directory is
|
||||||
|
one of those verbs, in the README's order, and every boundary between them is a seam a developer
|
||||||
|
already knows exists.
|
||||||
|
|
||||||
|
## 1. Principles
|
||||||
|
|
||||||
|
1. **The README's table of contents is the directory listing.** `Send an SMS` → `sending/`,
|
||||||
|
`Delivery reports` → `receipts/`, `Receive SMS` → `receiving/`, `Run an SMPP server` → `server/`,
|
||||||
|
`Client options`/reconnect → `client/`, `Session` → `session/`, `PDUs and the low-level API` →
|
||||||
|
`wire/`, `Encoding`/long messages → `text/`. Answers: Navigation stuck at 6 in every round, while
|
||||||
|
C, the one draft with grouped folders, reached Locality 6 on every seat.
|
||||||
|
2. **One socket, one `Session`, one life.** A `Session` goes `open → bound → closing → closed` and
|
||||||
|
never backwards. Reconnect is a separate `ClientSession` above it, whose state is a discriminated
|
||||||
|
union holding a `Session` only while one is up. Answers lesson 3: `LinkLife`'s phase plus
|
||||||
|
`stopped`, seven predicates, call-order-dependent `linkLost`/`end`/`dropSocket`, and
|
||||||
|
`ReconnectLoop`'s duplicate stopped flag. F showed that the split removes the unit; this plan keeps
|
||||||
|
F's split and fixes what F left, below.
|
||||||
|
3. **Every invariant has one owner, and that owner's file states it.** "Every inbound request gets
|
||||||
|
exactly one answer" → `session/owed-answer.ts`. "Nothing held without a bound" →
|
||||||
|
`limits/bounded-store.ts`. "A send that never reached a socket may go out on the next one" →
|
||||||
|
`client/next-link.ts`. Answers round three's "enforced jointly by `IncomingRequests` and
|
||||||
|
`sms.ts`", and D's "answered in three places".
|
||||||
|
4. **Lower areas never call up; they declare the port they need.** `receiving/`, `receipts/` and
|
||||||
|
`sending/` export functions and classes that take a narrow port type *declared in their own
|
||||||
|
file* (`AnswerPort`, `SendPort`, `report(err)`). `Session` implements the ports. Answers finding 4:
|
||||||
|
`IncomingRequests`/`HeldMessages` calling `emit`, `sendReturn`, `close` and `listenerCount` on
|
||||||
|
`Session`.
|
||||||
|
5. **A store enforces its own bound.** `BoundedStore` evicts, expires and weighs by itself, never
|
||||||
|
evicts the entry being admitted, keeps reads pure, and reports every removal through one
|
||||||
|
`onRemoved(key, value, reason)`. Answers round three's most-cited unit: `ExpiringGroups` plus
|
||||||
|
`Reassembler.trim`.
|
||||||
|
6. **A state change's effects sit inside the transition that causes them.** No reducer that returns
|
||||||
|
effects, and no callbacks wired from another file. Answers E, whose state machine scored no better
|
||||||
|
because each transition's meaning was split over five callbacks.
|
||||||
|
7. **Every SMPP term is glossed once, and every spec citation carries its half-line summary.**
|
||||||
|
`SMPP 3.4 5.2.12 (esm_class: message type and mode bits)`, never a bare `5.2.12`. The README gets
|
||||||
|
a glossary table (ESME, SMSC/MC, PDU, bind, esm_class, data_coding, UDH, sar_*, TLV, receipt,
|
||||||
|
segment). Answers lesson: juniors score Self-sufficiency 5 because of bare citations and
|
||||||
|
`data_coding` bit masks.
|
||||||
|
8. **Every default in one file.** `src/defaults.ts`, grouped by the verb that uses them. Answers
|
||||||
|
"defaults spread over several files".
|
||||||
|
9. **Hard parts are marked by one convention.** A hard part opens with an `Invariant:` paragraph at
|
||||||
|
the code it guards, which the comment rules permit, and AGENTS.md gets a "Where it is hard" list
|
||||||
|
naming the file of each. The panel's "7" asks for hard parts that are few, localized and marked.
|
||||||
|
|
||||||
|
## 2. Layout
|
||||||
|
|
||||||
|
Dependencies point downward in this order: `wire` ← `text` ← `limits` ← {`sending`, `receiving`,
|
||||||
|
`receipts`} ← `session` ← {`client`, `server`} ← `index.ts`. Root files are importable by all.
|
||||||
|
The only ways up are ports: types declared low, implemented by `session/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ fan-out 13: 4 files, 9 areas
|
||||||
|
index.ts Public surface, named exports only. Grouped by the README's sections.
|
||||||
|
defaults.ts Every default value, grouped by client/server/session/stores. No logic.
|
||||||
|
log.ts SmppLog, silentLog, guardedLog.
|
||||||
|
result.ts Result<T>, VoidResult, errorFrom(), namedValue(), UnansweredError.
|
||||||
|
|
||||||
|
wire/ "PDUs and the low-level API". Fan-out 6 + tables/
|
||||||
|
pdu.ts pduToObj/objToPdu/pduReturn/isCommand/isResp. Stateless, total.
|
||||||
|
framer.ts PduFramer: byte stream → complete PDUs. State: the partial buffer.
|
||||||
|
refusal.ts PduRefusedError, PduHeader, the status SMPP names for each refusal.
|
||||||
|
retained-pdu.ts detach() and retainedOctets(): what holding a PDU costs.
|
||||||
|
smpp-time.ts smppDate/smppTime: the 16-char time format, absolute and relative.
|
||||||
|
tables/ The spec, as data. Fan-out 7. Knows nothing above it but result.ts.
|
||||||
|
commands.ts 33 commands, ids, params in WIRE ORDER (invariant stated at the top).
|
||||||
|
constants.ts consts, constsById, interface versions.
|
||||||
|
alphabets.ts GSM 03.38 (named gsm7 internally; 'ASCII' only as the public name), LATIN1, UCS2 codecs; detection.
|
||||||
|
data-coding.ts data_coding → alphabet and message class; one row per coding group with its meaning in words.
|
||||||
|
errors.ts ESME_* by numeric id.
|
||||||
|
tlvs.ts TLV table by numeric id; typed read/write of a TLV stream.
|
||||||
|
types.ts Wire types int8…cstring, arrays.
|
||||||
|
index.ts defs, the grouped tables.
|
||||||
|
|
||||||
|
text/ "Encoding" and "Long messages". Fan-out 3. Pure functions.
|
||||||
|
message-text.ts encode/decode/bitCount/unencodable; where a PDU's body is (short_message vs message_payload).
|
||||||
|
splitting.ts splitMessage and segment budgets (153 GSM unpacked / 134 / 67); the "GSM is sent unpacked" note lives here.
|
||||||
|
concatenation.ts Both spellings, both directions: UDH build/parse, sar_* read, concatOf(), ConcatReference counter.
|
||||||
|
|
||||||
|
limits/ What is bounded, and waiting on it. Fan-out 3.
|
||||||
|
bounded-store.ts BoundedStore<V>: count cap, weight cap, expiry, one sweep timer, onRemoved(key, v, reason). Invariant: admit never evicts its own key; get() never mutates.
|
||||||
|
send-window.ts SendWindow: the maxOutstanding semaphore; idle(timeout) for the drain.
|
||||||
|
idle-waiters.ts Waiting for a count to reach zero within a budget.
|
||||||
|
|
||||||
|
sending/ "Send an SMS". Fan-out 2.
|
||||||
|
compose-submit.ts submit_sm params from SendSmsOptions: TON, messaging mode, flash, times, the alphabet check. Pure.
|
||||||
|
sms-sender.ts SmsSender: split, send every segment together through a SendPort, collect ids, tell the merger. State: ConcatReference, ReceiptMerger.
|
||||||
|
|
||||||
|
receipts/ "Delivery reports". Fan-out 5.
|
||||||
|
receipt-states.ts message_state ↔ stat: codes, FAILED/DELIVERD aliases, which states are transient. Invariant: only ENROUTE/SCHEDULED are not final.
|
||||||
|
read-receipt.ts dlrFromPdu(), parseReceipt(): esm_class, then TLV, then body.
|
||||||
|
write-receipt.ts The deliver_sm a sendDlr() sends: text, TLVs, esm_class 0x04 vs 0x20. Pure.
|
||||||
|
receipt-merger.ts ReceiptMerger: per-segment receipts into one MessageDlr. State: open groups, spent bases (BoundedStore x2). `close` renamed `spend`.
|
||||||
|
message-ids.ts uuidv7(), <base>-<n>, smsIdFormat normalisation, which response carries an id.
|
||||||
|
|
||||||
|
receiving/ "Receive SMS" and "Receiving in depth". Fan-out 4.
|
||||||
|
receive-message.ts The one path an inbound message takes: bound check → concat? → reassemble → answer segment → hand whole message to handlers. Takes AnswerPort per request.
|
||||||
|
reassembler.ts Reassembler: incomplete groups in a BoundedStore; lost groups reported once.
|
||||||
|
running-handlers.ts RunningHandlers: onSms calls in flight. State: count, weight, deadline per call. Invariant: return releases, throw refuses with the retry status, no handler refuses at once. Its SendPort lets a running handler's sendDlr pass the drain.
|
||||||
|
sms.ts The Sms handle: fields decoded once; sendResp → the message's OwedAnswers; sendDlr → write-receipt via SendPort.
|
||||||
|
|
||||||
|
session/ "Session". One socket's life. Fan-out 8.
|
||||||
|
session.ts Session (public): state 'open'|'bound'|'closing'|'closed' in one field; bound(), send, sendSms, sendReturn, close, unbind; emits. Each transition method carries its own effects.
|
||||||
|
session-options.ts SessionOptions type and its checks.
|
||||||
|
transport.ts One socket + framer + write(). No attach(): a socket never changes.
|
||||||
|
heartbeat.ts enquire_link on quiet, idle timeout. State: two timers.
|
||||||
|
requests.ts Outgoing: seqNr, pending map, response timeout, window slot, abort, UnansweredError. Merges pending-requests + outgoing-requests minus link logic.
|
||||||
|
dispatch.ts An inbound PDU's route: response → requests; request → onRequest hook → bind direction → enquire_link/unbind/re-bind/unknown/message/receipt.
|
||||||
|
owed-answer.ts OwedAnswer: created for every inbound request at dispatch; the only writer of a response. State: owed|answered|lost. Implements AnswerPort.
|
||||||
|
bind-direction.ts What each bind type carries per link end; data_sm stands in for submit_sm or deliver_sm; checkedBind().
|
||||||
|
shutdown.ts The drain: handlers first, then requests, one deadline, the err naming what was lost.
|
||||||
|
|
||||||
|
client/ "Client options" and reconnect. Fan-out 5.
|
||||||
|
client.ts client(), ClientSession (public): state {kind:'connecting'} | {kind:'up', session} | {kind:'down', attempt} | {kind:'closed'}. Forwards events of the current Session; owns SmsSender so merges survive a reconnect.
|
||||||
|
client-options.ts ClientOptions and their checks (connectTimeout, reconnect spelling, fromStart).
|
||||||
|
connect.ts Open a socket: TCP or TLS, connectTimeout over both.
|
||||||
|
bind.ts bind_* params, the bind response → session.bound().
|
||||||
|
reconnect.ts Backoff as a pure function (delay, upSince) → next delay, and the retry timer. No stopped flag: the ClientSession's 'closed' state is the stop.
|
||||||
|
next-link.ts Sends waiting for a bound Session, one budget each; a send that never reached a socket retries here. Invariant: nothing that reached a socket is resent.
|
||||||
|
|
||||||
|
server/ "Run an SMPP server" and "Server in depth". Fan-out 3.
|
||||||
|
server.ts server(), SmppServer: listener, sessions set, close() = stop listening then drain each.
|
||||||
|
server-options.ts ServerOptions and their checks.
|
||||||
|
accept-bind.ts Pre-bind requests, authenticate, bind_resp with sc_interface_version, Session.bound().
|
||||||
|
```
|
||||||
|
|
||||||
|
Deepest level is 3 (`src/wire/tables/`). Maximum fan-out 13 at `src/`, otherwise ≤ 8.
|
||||||
|
|
||||||
|
## 3. Public API changes
|
||||||
|
|
||||||
|
Three changes; everything else in the README keeps its spelling, including `client()` resolving
|
||||||
|
`{ err, session }`, so every send and receipt example survives untouched.
|
||||||
|
|
||||||
|
1. **Inbound messages reach an `onSms` handler option instead of an `sms` event.**
|
||||||
|
- Old: `session.on('sms', async sms => { await sms.sendResp(); })`, and a server's
|
||||||
|
`smpp.on('session', s => s.on('sms', …))`.
|
||||||
|
- New: `client({ onSms })`, `server({ onSms })`, `new Session({ onSms })`, typed
|
||||||
|
`(sms: Sms) => Promise<void> | void`. `sms.sendResp()` stays the only way to answer, with the same
|
||||||
|
options. Returning releases the message, answering `ESME_ROK` first if `sendResp()` was not called.
|
||||||
|
Throwing or rejecting refuses it with the retry status (`ESME_RTHROTTLED` or `ESME_RX_T_APPN`)
|
||||||
|
unless it is already answered, and reports it on `sessionError`. With no `onSms`, every message is
|
||||||
|
refused at once with that retry status and one `warn` is logged. `sendDlr()` before the answer
|
||||||
|
returns `err`. `sms.answeredOnArrival` stays.
|
||||||
|
- Removes: the held-message timing contract (lesson 1): listener counts, the `setImmediate` turn,
|
||||||
|
`captureRejections` routed through a `WeakMap`, and "answered" living in three places, because
|
||||||
|
`answeredOnArrival` becomes a getter over the OwedAnswers.
|
||||||
|
- Serves goal 2 (a crash before the answer leaves the peer to resend, unlike C) and goal 5. The
|
||||||
|
no-handler refusal is a judgement call that goes in docs/decisions.md, resting on goal 2's "work
|
||||||
|
the peer has no reason to send again is not dropped".
|
||||||
|
- Migration is one line per listener: the handler body is unchanged.
|
||||||
|
2. **A `Session` is one socket; reconnect is `ClientSession`, which `client()` returns.**
|
||||||
|
- Old: `Session` with a `reconnect: { connect, onConnected }` option, `disconnected` and
|
||||||
|
`reconnected` events, and `sock` swapped under it.
|
||||||
|
- New: `Session` has no `reconnect` option, no `disconnected`/`reconnected`, and a fixed `sock`.
|
||||||
|
`client()` still resolves `{ err, session }`, where `session` is a `ClientSession` with today's
|
||||||
|
client methods (`sendSms`, `send`, `unbind`, `close`, `boundAs`, `peerInterfaceVersion`,
|
||||||
|
`acceptsOptionalParams()`, `bindAllows()`) and events (`close`, `disconnected`, `reconnected`,
|
||||||
|
`dlr`, `messageDlr`, `sessionError`, plus `data`/`incomingPdu`/`incomingPduObj` forwarded from
|
||||||
|
the current link). `session.sock` and `session.sendReturn()` move to `session.link`, the current
|
||||||
|
`Session` or `undefined` while down, because both belong to one socket. A hand-wired ESME that
|
||||||
|
wants reconnect builds a new `Session` per socket.
|
||||||
|
- Removes: `LinkLife` and its predicates, the call-order hazard, `ReconnectLoop`'s duplicate stop,
|
||||||
|
and client.ts's `bindOn` depending on another file's ordering (lesson 3).
|
||||||
|
- Serves goal 8 (a smaller surface, a stated scope for `Session`) and goal 4 (one stop state, so no
|
||||||
|
path rebinds after close).
|
||||||
|
3. **`linkEnd` becomes a readonly constructor option on `Session`** (old: a writable field
|
||||||
|
`session.linkEnd = 'smsc'`). A field mutable after dispatch starts is a hidden state a reader has
|
||||||
|
to chase through `bind-direction.ts`. Serves goal 4, since the bind direction decides what is
|
||||||
|
refused.
|
||||||
|
|
||||||
|
Kept deliberately: `encoding: 'ASCII'` as the public name of GSM 03.38 (inherited from 0.4.0,
|
||||||
|
MIGRATION.md relies on it). Internally the alphabet is `gsm7` everywhere, and `alphabets.ts` glosses
|
||||||
|
the public name once. `sessionError`'s kinds stay told apart by type and message: no panel cited them.
|
||||||
|
|
||||||
|
## 4. Where each thing goes
|
||||||
|
|
||||||
|
| Now | New home |
|
||||||
|
| --- | --- |
|
||||||
|
| client.ts | client/client.ts (client(), fromStart), client/connect.ts (openSocket, connectTimeout), client/bind.ts |
|
||||||
|
| server.ts | server/server.ts, server/accept-bind.ts (authenticate, pre-bind, bind_resp) |
|
||||||
|
| session.ts | session/session.ts (state, API, emit guard); drain → session/shutdown.ts; dispatch/refuse → session/dispatch.ts |
|
||||||
|
| sms.ts | receiving/sms.ts (handle, sendResp); receipt building → receipts/write-receipt.ts |
|
||||||
|
| concat.ts, udh.ts | text/concatenation.ts |
|
||||||
|
| dlr.ts | receipts/read-receipt.ts, receipts/receipt-states.ts |
|
||||||
|
| dlr-merger.ts | receipts/receipt-merger.ts (`close` → `spend`) |
|
||||||
|
| error-from.ts, unanswered-error.ts, result.ts | result.ts |
|
||||||
|
| expiring-groups.ts | limits/bounded-store.ts (enforcing its own caps) |
|
||||||
|
| held-messages.ts | receiving/running-handlers.ts |
|
||||||
|
| idle-waiters.ts, send-window.ts | limits/ |
|
||||||
|
| incoming-requests.ts | session/dispatch.ts (routing, bind direction, unbind, unknown) + receiving/receive-message.ts (message path, store bound) |
|
||||||
|
| link-life.ts | deleted: the state goes to session.ts's one field; waiting for a link goes to client/next-link.ts |
|
||||||
|
| link-timers.ts | session/heartbeat.ts |
|
||||||
|
| log.ts, result.ts | root |
|
||||||
|
| message.ts | text/message-text.ts, text/splitting.ts; smppDate/smppTime → wire/smpp-time.ts |
|
||||||
|
| message-body.ts | text/message-text.ts |
|
||||||
|
| outgoing-requests.ts, pending-requests.ts | session/requests.ts; the next-link retry → client/next-link.ts |
|
||||||
|
| pdu.ts, pdu-framer.ts, pdu-refusal.ts, retained-pdu.ts | wire/ |
|
||||||
|
| pdu-transport.ts | session/transport.ts, without attach() |
|
||||||
|
| reassembly.ts | receiving/reassembler.ts; decodeSegments → text/message-text.ts |
|
||||||
|
| reconnect-loop.ts | client/reconnect.ts |
|
||||||
|
| send-sms.ts | sending/compose-submit.ts + sending/sms-sender.ts |
|
||||||
|
| session-options.ts | defaults → defaults.ts; checks → each area's *-options.ts; bindCarries/standsInFor/checkedBind → session/bind-direction.ts |
|
||||||
|
| sms-id.ts, uuid.ts | receipts/message-ids.ts |
|
||||||
|
| defs/* | wire/tables/*; encodings.ts split into alphabets.ts and data-coding.ts |
|
||||||
|
|
||||||
|
Named-hard responsibilities:
|
||||||
|
|
||||||
|
| Responsibility | Home and owner |
|
||||||
|
| --- | --- |
|
||||||
|
| Held messages and answering | session/owed-answer.ts (one answer per request); receiving/running-handlers.ts (the handler's life) |
|
||||||
|
| Lifecycle and reconnect | session/session.ts (one-way state); client/client.ts (union state), client/reconnect.ts (backoff) |
|
||||||
|
| The drain | session/shutdown.ts, one function; a running handler's sends admitted through its own SendPort |
|
||||||
|
| Outgoing requests and retry | session/requests.ts (one link, no retry); client/next-link.ts (the only retry) |
|
||||||
|
| Reassembly and ExpiringGroups | receiving/reassembler.ts over limits/bounded-store.ts |
|
||||||
|
| Receipts and merging | receipts/ (read, states, write, merger); merger owned by SmsSender, which ClientSession holds across links |
|
||||||
|
| The codec | wire/pdu.ts over wire/tables/ |
|
||||||
|
| Encodings | wire/tables/alphabets.ts, wire/tables/data-coding.ts; text/ above them |
|
||||||
|
| Defaults | src/defaults.ts |
|
||||||
|
| Domain knowledge and glossary | README glossary table; summarised citations; the defect table and "GSM is sent unpacked" stay in AGENTS.md, with a pointer line in splitting.ts |
|
||||||
|
|
||||||
|
## 5. The hard parts that stay hard
|
||||||
|
|
||||||
|
Each is marked with an `Invariant:` paragraph in its file and listed under "Where it is hard" in
|
||||||
|
AGENTS.md.
|
||||||
|
|
||||||
|
1. **Exactly one answer per inbound request** (session/owed-answer.ts). The hardness is SMPP's: a
|
||||||
|
multipart message is answered per segment on arrival, a single one when the application says, a
|
||||||
|
refused PDU from its header alone, and never on a link that is gone. Localized, since OwedAnswer
|
||||||
|
is the only writer, and a test asserts no other file calls `transport.write` with a response.
|
||||||
|
2. **The drain's order and budgets** (session/shutdown.ts). Handlers first, because a handler's
|
||||||
|
answer can put a receipt on the wire. `shutdownTimeout: 0` still bounds the handler half. One
|
||||||
|
function, about 40 lines, with its budget rule in its signature.
|
||||||
|
3. **Retry only what never reached the socket** (client/next-link.ts). Goal 2's "never re-sent on
|
||||||
|
the library's own initiative". The rule is one predicate over the `requests.ts` result
|
||||||
|
(`written: false`), and the loop lives only here.
|
||||||
|
4. **Bounded stores and eviction order** (limits/bounded-store.ts). The reassembly weight rule, 1000
|
||||||
|
plus octets plus 300 per TLV, stays in receiving/reassembler.ts as one function beside its
|
||||||
|
README-facing constant.
|
||||||
|
5. **data_coding coding groups** (wire/tables/data-coding.ts). Bit masks are unavoidable. One table
|
||||||
|
row per group states in words what the group means and whether it carries a class.
|
||||||
|
6. **Receipt classification** (receipts/read-receipt.ts). esm_class, then TLV, then text, with
|
||||||
|
operator aliases. It is operator folklore, so each alias names the operator in one line.
|
||||||
|
|
||||||
|
## 6. Build order
|
||||||
|
|
||||||
|
Each chunk keeps the suite green. Tests move to the new API only in chunks 5 and 6, the two
|
||||||
|
contract changes.
|
||||||
|
|
||||||
|
1. **Tables and codec.** Move defs/ to wire/tables/, split encodings, rename gsm7 internally, and
|
||||||
|
move pdu, framer, refusal, retained and time into wire/. Add summaries to every citation.
|
||||||
|
2. **text/ and receipts/.** Pure moves plus `spend`. message-ids absorbs uuid.
|
||||||
|
3. **limits/.** BoundedStore replaces ExpiringGroups, with its own tests for "admit never evicts
|
||||||
|
itself" and "reads are pure". Reassembler, merger and held messages port onto it.
|
||||||
|
4. **defaults.ts, the *-options.ts files, sending/.** SmsSender with its SendPort.
|
||||||
|
5. **Contract 1: onSms.** Add owed-answer.ts, receiving/, and session/dispatch.ts. Delete
|
||||||
|
held-messages and incoming-requests. Port the `sms` listeners in tests and README, and add the
|
||||||
|
decision record for the no-handler refusal.
|
||||||
|
6. **Contract 2: one-socket Session, ClientSession.** Session gets its one-way state, plus
|
||||||
|
transport without attach, requests, heartbeat and shutdown. The client gets client/,
|
||||||
|
next-link and reconnect. Delete link-life. Port the reconnect tests to ClientSession, and make
|
||||||
|
`linkEnd` an option.
|
||||||
|
7. **server/** split, index.ts regrouped by README section, the README glossary, AGENTS.md
|
||||||
|
architecture, and "Where it is hard".
|
||||||
|
8. **Run the comprehension panel.** Fix only what it names inside the hard-parts list.
|
||||||
|
|
||||||
|
## 7. Predicted panel risks
|
||||||
|
|
||||||
|
- **ClientSession forwarding** (client/client.ts). It re-emits the current Session's events and
|
||||||
|
answers `boundAs` through the gap. A reader asks "which object do I listen on?" Mitigation: README
|
||||||
|
Events table gains one column, `Session` / `ClientSession`. Likely Shape 6 on the senior's seat if
|
||||||
|
the forwarding list is long.
|
||||||
|
- **Two SmsSenders in client mode.** A Session inside a ClientSession has its own unused sender, and
|
||||||
|
the ClientSession's merger is fed from the link's `dlr`. Alternative: Session takes an optional
|
||||||
|
injected SmsSender. Pick one at chunk 6 and state it in client.ts's invariant.
|
||||||
|
- **Ports feel like indirection to the junior.** `AnswerPort`/`SendPort` add names. Mitigation: each
|
||||||
|
port is 1–3 members and declared in the file that uses it.
|
||||||
|
- **Root fan-out of 13, and `limits/` is a new abstraction name.** A mid may look for the send window
|
||||||
|
under session/. It is cross-referenced from requests.ts's constructor argument only.
|
||||||
|
- **The no-handler refusal** surprises a transceiver client that never expected MO traffic: its SMSC
|
||||||
|
retries forever. That is a goal-2-correct outcome, but a reader may argue it; the decision record
|
||||||
|
must carry the argument.
|
||||||
|
- **The coarse scale.** Removing a unit has moved the hardest unit elsewhere every round. The most
|
||||||
|
likely next candidate is `owed-answer.ts` + `receive-message.ts`, which could hold a mean at 6.5
|
||||||
|
rather than 7. The mitigation is to keep it the only writer and test that.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Plan 2: state ownership first
|
||||||
|
|
||||||
|
Every piece of mutable state has one owner. The owner is the only writer, states its invariant in one
|
||||||
|
paragraph above the class, and enforces it in the same file. Other files read state through the
|
||||||
|
owner's methods, or learn about it from a result the owner returns. Nothing reaches back up.
|
||||||
|
|
||||||
|
## 1. Principles
|
||||||
|
|
||||||
|
1. **One object per socket, never reused.** A `Link` is born with a socket and dies with it. Its
|
||||||
|
phase only moves forward: `binding → bound → gone`, or `binding → gone`. There is no `attach()`, no
|
||||||
|
`stopped` flag and no initial state that breaks its own rule. This answers lessons 3 and round 3:
|
||||||
|
LinkLife's 4 phases plus `stopped`, the initial `'up'`, and `linkLost`/`dropSocket`/`comeBackUp`,
|
||||||
|
whose correctness depended on call order. F showed that a one-socket unit removes the lifecycle as
|
||||||
|
the hardest spot. This plan keeps that idea behind the **current** public `Session`, so the public
|
||||||
|
rename F needed is avoided.
|
||||||
|
2. **Two state fields for the session's life, one per owner, and neither derives from the other.**
|
||||||
|
`Session.life: 'open' | 'closing' | 'ended'` belongs to `Session`. `Link.phase` belongs to the
|
||||||
|
`Link`. "Can send now" is `life === 'open' && link?.phase === 'bound'`, written once in
|
||||||
|
`Session.boundLink()`. The seven predicates of lesson 3 collapse to that one method.
|
||||||
|
3. **Stoppedness has one writer.** `Session` holds an `AbortController` called `lifetime` and aborts
|
||||||
|
it in the same statement that leaves `'open'`. The reconnect loop, the waits for a link and the
|
||||||
|
drain only read `lifetime.signal`. This removes ReconnectLoop's duplicate stopped flag (lesson 3)
|
||||||
|
and client.ts's "close() must reach stop() before its first await" comment.
|
||||||
|
4. **A transition finishes before anyone hears about it.** Each transition method writes every field
|
||||||
|
first and emits last. A listener that calls `close()` from `disconnected` or `close` finds
|
||||||
|
`life === 'ended'` and gets `{}` back. This answers lesson 3's synchronous re-entry.
|
||||||
|
5. **The inbound answer has one owner.** `link/answers.ts` is the only code that writes a response
|
||||||
|
PDU, and it writes at most one per inbound sequence number. `sendReturn()`, bind handling, refusals,
|
||||||
|
segments answered on arrival and the value an `onSms` handler returns all go through it. This
|
||||||
|
answers round 3's "exactly one answer, enforced jointly by IncomingRequests and sms.ts", and D's
|
||||||
|
"answered in three places".
|
||||||
|
6. **Receiving is a handler, answered on return** (C/D/E/F: the held-message timing contract capped
|
||||||
|
every seat). The handler's promise is the hold. There is no `setImmediate`, no listener count, and
|
||||||
|
no WeakMap back from a `captureRejections` payload.
|
||||||
|
7. **A bounded store enforces its own bounds.** `BoundedStore` checks the count, the weight and the
|
||||||
|
deadline itself. Reads never mutate. Only `admit()`, `grow()` and its own timer drop entries, and
|
||||||
|
every drop goes to one `onDrop(value, reason)` given at construction. It never drops the entry the
|
||||||
|
caller is writing: it refuses that write instead. This answers round 3's ExpiringGroups finding,
|
||||||
|
named by 4 of 8 seats.
|
||||||
|
8. **Each spec citation says what it cites, and each bit mask has a name.** `wire/fields.ts` names
|
||||||
|
every `esm_class`, `data_coding` and `registered_delivery` field, with one line each. Every
|
||||||
|
`SMPP 3.4 x.y.z` cite carries a clause saying what that section requires. The terms are defined in
|
||||||
|
`docs/glossary.md`. This answers the juniors' Self-sufficiency 5, whose cost was citations with no
|
||||||
|
summary and bare masks rather than a missing glossary.
|
||||||
|
9. **Defaults live in one table.** `src/defaults.ts` holds every default and every internal cap, with
|
||||||
|
one line of why each (lesson 4).
|
||||||
|
|
||||||
|
## 2. Layout
|
||||||
|
|
||||||
|
Imports point one way: `wire ← text ← receipts ← link ← session ← client, server`. `bounded-store`,
|
||||||
|
`result`, `log` and `uuid` are leaves that any area may import. `link` never imports `session`.
|
||||||
|
Instead it reports to its owner through `LinkOwner`, a typed interface of five callbacks declared in
|
||||||
|
`link/link.ts`.
|
||||||
|
|
||||||
|
Fan-out: the root has 13 entries (6 leaf files and 7 areas). Each area has 3–11 files. The tree is
|
||||||
|
at most two levels deep below `src/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
index.ts Public surface, named exports only. No state.
|
||||||
|
defaults.ts Every default and internal cap (client, server, session, stores), one line of why each. No state.
|
||||||
|
result.ts Result<T>, VoidResult, errorFrom(): a thrown value turned into a result. No state.
|
||||||
|
log.ts SmppLog, silentLog, guardedLog. No state.
|
||||||
|
uuid.ts uuidv7(). State: the monotonic counter within one millisecond.
|
||||||
|
bounded-store.ts BoundedStore<V>. State: entries, total weight, sweep timer. Invariants: count <= max and weight <= maxWeight
|
||||||
|
after every write; expired entries are gone before admit() decides; the entry being written is never
|
||||||
|
dropped; every drop goes through onDrop exactly once.
|
||||||
|
wire/ The codec: bytes <-> PduObject. Stateless apart from PduFramer. (11 files)
|
||||||
|
commands.ts The 33 commands, their ids and params in WIRE ORDER (never sorted).
|
||||||
|
constants.ts consts + constsById, the interface versions.
|
||||||
|
fields.ts Named bit fields of esm_class, data_coding and registered_delivery, one line of meaning each.
|
||||||
|
Replaces hasUdh/messageTypeOf/messageClassOf's inline masks.
|
||||||
|
errors.ts ESME_* status table, ordered by id.
|
||||||
|
tlvs.ts TLV table, ordered by id; typed read/input shapes.
|
||||||
|
tlv-stream.ts Reading and writing a TLV stream. Split out of tlvs.ts.
|
||||||
|
types.ts Integer and C-Octet String wire types.
|
||||||
|
array-types.ts dest_address and unsuccess_sme arrays. Split out of types.ts (684 lines).
|
||||||
|
pdu.ts pduToObj / objToPdu / pduReturn / isCommand / isResp.
|
||||||
|
refusal.ts PduHeader, PduRefusedError, the status SMPP names for an unreadable PDU, maxPduLength.
|
||||||
|
framer.ts PduFramer. State: the partial-PDU buffer. Invariant: yields only whole PDUs; one bad length poisons the stream.
|
||||||
|
defs.ts `defs`: every table as one group.
|
||||||
|
text/ Message bodies: alphabets, splitting, concatenation, times. Stateless. (9 files)
|
||||||
|
gsm7.ts GSM 03.38 codec, named for what it is. The public EncodingName 'ASCII' maps to it in one line in alphabets.ts.
|
||||||
|
latin1.ts, ucs2.ts One codec each.
|
||||||
|
alphabets.ts detect, unencodable, encodingByDataCoding, dataCodingByEncoding.
|
||||||
|
split.ts splitMessage, bitCount, the 153-septet/134-octet segment budget (see §5).
|
||||||
|
udh.ts UDH length and concat fields; ConcatReference. State: the 8-bit reference counter.
|
||||||
|
concat.ts concatOf(): UDH or sar_*, which spelling.
|
||||||
|
body.ts messageOctets, decodeMessage, decodeSegments: where a body is and how it reads.
|
||||||
|
smpp-time.ts smppDate, smppTime encode/decode.
|
||||||
|
receipts/ Delivery reports. (4 files)
|
||||||
|
receipt-text.ts parseReceipt, receiptCodes, the researched stat: aliases (FAILED, DELIVERD).
|
||||||
|
dlr.ts dlrFromPdu: what marks a receipt, final vs intermediate.
|
||||||
|
sms-id.ts Id notations, <base>-<n> segment ids, which response carries an id.
|
||||||
|
merger.ts DlrMerger. State: expected groups plus the `spent` bases (two BoundedStores). Invariant: a base
|
||||||
|
merges at most once; an intermediate report never fills a slot. `close()` is renamed `spend()`.
|
||||||
|
link/ One socket's life. Everything here dies with the socket. (9 files)
|
||||||
|
link.ts Link and LinkOwner. State: phase (binding|bound|gone), gone-reason. Composes the files below.
|
||||||
|
Invariant: the phase only moves forward, and owner.onGone fires exactly once.
|
||||||
|
transport.ts Socket plus framer. State: none beyond the socket. The only write path to the socket.
|
||||||
|
timers.ts enquire_link heartbeat and idle timeout. State: two timers. Cleared when the phase is gone.
|
||||||
|
pending.ts Sequence numbers and correlation. State: next seqNr, seqNr -> waiter. Invariant: every waiter settles
|
||||||
|
once (answered, timed out, aborted, or unanswered when the link goes).
|
||||||
|
bind.ts The bind record {as, peerVersion}, checkedBind, bindCarries, standsInFor. State: the record. Set once.
|
||||||
|
answers.ts The answer ledger. State: owed requests by seqNr. Invariant: every inbound request gets exactly one
|
||||||
|
response on this link, or none because the link went; a second answer returns err.
|
||||||
|
inbound.ts Routes each inbound request to its one answer: onRequest hook, bind direction, enquire_link, unbind,
|
||||||
|
submit/deliver/data_sm, unknown commands. Stateless apart from what it calls.
|
||||||
|
handlers.ts Running onSms handlers. State: running set (a BoundedStore, 'refuse' policy), the refusing
|
||||||
|
hysteresis flag. Invariant: at most maxHeldMessages/maxHeldOctets run at once; the drain waits for zero.
|
||||||
|
reassembly.ts Reassembler. State: incomplete groups (a BoundedStore). Invariant: a segment is answered only once
|
||||||
|
it is kept; a group lost after answering is reported as lost traffic.
|
||||||
|
session/ What the application holds: one Session over a sequence of Links. (9 files)
|
||||||
|
session.ts Session. State: life, current link, last bound link, lifetime AbortController. Owns the transitions
|
||||||
|
open -> closing -> ended, and the link handover. Public events are emitted here and nowhere else.
|
||||||
|
link-wait.ts LinkWait. State: sends waiting for a bound link. Invariant: each settles on the next bound link, its
|
||||||
|
own deadline or signal, or lifetime abort.
|
||||||
|
window.ts SendWindow (maxOutstanding). State: in-flight count, FIFO queue. Invariant: in-flight <= max.
|
||||||
|
outbound.ts The one request path: admit -> wait for a link -> slot -> write -> answer; retry only if unwritten.
|
||||||
|
UnansweredError. No state of its own.
|
||||||
|
reconnect.ts Backoff. State: delay, upAt, timer. Stopped only through the lifetime signal.
|
||||||
|
shutdown.ts The drain order and budgets as one function over (handlers, window, lifetime). No state.
|
||||||
|
sms.ts The Sms handle given to onSms: fields, sendDlr(). No state beyond the message.
|
||||||
|
send-sms.ts submitSms composition, submitSmParams.
|
||||||
|
options.ts SessionOptions, ReconnectOptions, CloseOptions, SendOptions, checkSessionOptions.
|
||||||
|
client/ (3 files)
|
||||||
|
client.ts client(): compose, fromStart.
|
||||||
|
connect.ts Open a socket: TCP/TLS, connectTimeout, abort.
|
||||||
|
bind.ts The ESME's bind_* request and what a refusal means.
|
||||||
|
server/ (3 files)
|
||||||
|
server.ts server(), SmppServer. State: the listener and the live sessions set.
|
||||||
|
listen.ts Listener creation, TLS, startup errors.
|
||||||
|
accept-bind.ts authenticate, bind response, pre-bind refusals.
|
||||||
|
docs/glossary.md ESME, SMSC/MC, PDU, bind types, esm_class, data_coding, UDH, sar_*, TLV, message_state, receipt, segment, link, session.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Public API changes
|
||||||
|
|
||||||
|
These are the only changes. `client()` still resolves `{ err, session }`, `Session` keeps its events
|
||||||
|
(`close`, `disconnected`, `reconnected`, `dlr`, `messageDlr`, `sessionError`, `data`, `incomingPdu*`),
|
||||||
|
and the codec exports stay.
|
||||||
|
|
||||||
|
1. **`session.on('sms')` plus `sms.sendResp()` becomes an `onSms` option.** The option is accepted by
|
||||||
|
`client()`, `server()` and `new Session()`.
|
||||||
|
- Old: `session.on('sms', async sms => { await sms.sendResp({ smsId }); })`.
|
||||||
|
- New: `onSms: sms => ({ smsId })` (sync or async). Returning nothing answers `ESME_ROK` under a
|
||||||
|
generated id. `{ status }` refuses the message.
|
||||||
|
- A handler that throws or rejects refuses the message with the retry status: `ESME_RTHROTTLED` on
|
||||||
|
a submission, `ESME_RX_T_APPN` on a delivery. The error also goes to `sessionError`.
|
||||||
|
- With no `onSms`, the same retry status goes out at once. That needs a decision in
|
||||||
|
`docs/decisions.md` resting on goal 2 ("work the peer has no reason to send again is not dropped")
|
||||||
|
and goal 4. Today no listener means no answer, and the peer's own timeout decides.
|
||||||
|
- Reason: it removes the held-message timing contract (lessons 1–2) and the `captureRejections`
|
||||||
|
WeakMap. Goals 2 and 5.
|
||||||
|
2. **`sms.sendDlr()` before the message is answered returns `err`:** "report after onSms returns". The
|
||||||
|
answer is what gives the peer the id that the receipt names. Reason: without this rule there would
|
||||||
|
be a second answering path (F kept `sendResp()` for an early answer, which is two spellings of
|
||||||
|
answering). Goal 1.
|
||||||
|
3. **`session.sendReturn()` on a request that is already answered, or on a message whose `onSms` is
|
||||||
|
still running, returns `err`.** Today it writes a second response. Reason: principle 5, since the
|
||||||
|
ledger's invariant is also the public promise. Goals 1 and 4.
|
||||||
|
4. **`sms.answeredOnArrival` stays.** For a message whose segments were answered on arrival, a
|
||||||
|
returned `smsId` or refusing `status` goes to `sessionError`, where today `sendResp()` returns it as
|
||||||
|
an `err`.
|
||||||
|
|
||||||
|
Kept on purpose: the `Session` name, `boundAs`/`peerInterfaceVersion` surviving the reconnect gap (now
|
||||||
|
read from the last bound Link), the `sock` getter (the current or last Link's socket), and the
|
||||||
|
`reconnect.connect`/`onConnected` constructor options. F's rename to `SmppClient` did not move the
|
||||||
|
mean (6.25 = main), so its cost buys nothing a reader scores. MIGRATION.md and CHANGELOG.md record
|
||||||
|
changes 1–4.
|
||||||
|
|
||||||
|
## 4. Where each thing goes
|
||||||
|
|
||||||
|
| Now | New home |
|
||||||
|
| --- | --- |
|
||||||
|
| session.ts | session/session.ts (life, handover, events); the dispatch moves to link/inbound.ts, the refusal answer to link/answers.ts, the drain to session/shutdown.ts |
|
||||||
|
| link-life.ts | Split: the phase goes to Link.phase; waiting for a link goes to session/link-wait.ts; `stopped` and `retrying` go to Session.life plus lifetime; `generation()` is deleted, because a message holds its Link object |
|
||||||
|
| client.ts | client/client.ts, client/connect.ts, client/bind.ts; `bindOn`'s ordering comment is deleted (principle 3) |
|
||||||
|
| server.ts | server/server.ts, server/listen.ts, server/accept-bind.ts |
|
||||||
|
| incoming-requests.ts | link/inbound.ts (routing); throttled and refused-segment statuses go to link/handlers.ts and link/reassembly.ts; `reportLost` becomes LinkOwner.onLost |
|
||||||
|
| held-messages.ts, sms.ts (MessageHold) | link/handlers.ts (the running set and bound); session/sms.ts (the handle); answering goes to link/answers.ts |
|
||||||
|
| outgoing-requests.ts | session/outbound.ts; `requestPastDrain`/`requestOnCurrentLink` become one `request(input, { lane })`, with `lane: 'app' | 'receipt' | 'bind' | 'unbind'` and a single `admit(lane, life)` table |
|
||||||
|
| pending-requests.ts | link/pending.ts (per link, so linkLost is the Link going) |
|
||||||
|
| send-window.ts, idle-waiters.ts | session/window.ts; the idle wait is inlined there and in link/handlers.ts (the abort dance is copied, per the existing decision) |
|
||||||
|
| link-timers.ts | link/timers.ts |
|
||||||
|
| reconnect-loop.ts | session/reconnect.ts; `halted` is deleted in favour of the lifetime signal |
|
||||||
|
| pdu-transport.ts, pdu-framer.ts | link/transport.ts (no `attach`: one socket per Link), wire/framer.ts |
|
||||||
|
| pdu.ts, pdu-refusal.ts, retained-pdu.ts | wire/pdu.ts, wire/refusal.ts; `detach`/`retainedOctets` go to bounded-store's weigher callers in link/ |
|
||||||
|
| expiring-groups.ts | bounded-store.ts |
|
||||||
|
| reassembly.ts | link/reassembly.ts; `decodeSegments` goes to text/body.ts |
|
||||||
|
| dlr-merger.ts, dlr.ts, sms-id.ts | receipts/merger.ts, receipts/dlr.ts plus receipt-text.ts, receipts/sms-id.ts |
|
||||||
|
| message.ts, message-body.ts, udh.ts, concat.ts | text/split.ts plus smpp-time.ts, text/body.ts, text/udh.ts, text/concat.ts |
|
||||||
|
| send-sms.ts | session/send-sms.ts |
|
||||||
|
| session-options.ts | session/options.ts; `defaults` goes to defaults.ts; bind helpers go to link/bind.ts |
|
||||||
|
| error-from.ts, unanswered-error.ts | result.ts, session/outbound.ts |
|
||||||
|
| log.ts, result.ts, uuid.ts | unchanged at the root |
|
||||||
|
| defs/* | wire/*, with defs/encodings.ts split into text/gsm7.ts, latin1.ts, ucs2.ts and alphabets.ts, and masks into wire/fields.ts |
|
||||||
|
|
||||||
|
The responsibilities the panels named hard:
|
||||||
|
|
||||||
|
- **Held messages and answering:** link/answers.ts owns the one answer; link/handlers.ts owns the
|
||||||
|
running handler and its bound; link/inbound.ts calls the two in sequence.
|
||||||
|
- **Lifecycle and reconnect:** session/session.ts (life plus handover), link/link.ts (phase),
|
||||||
|
session/reconnect.ts (backoff).
|
||||||
|
- **The drain:** session/shutdown.ts.
|
||||||
|
- **Outgoing requests and retry:** session/outbound.ts (one loop), link/pending.ts (correlation),
|
||||||
|
session/window.ts, session/link-wait.ts.
|
||||||
|
- **Reassembly and ExpiringGroups:** link/reassembly.ts on bounded-store.ts.
|
||||||
|
- **Receipts and merging:** receipts/.
|
||||||
|
- **Codec:** wire/.
|
||||||
|
- **Encodings:** text/.
|
||||||
|
- **Defaults:** defaults.ts.
|
||||||
|
- **Domain knowledge:** docs/glossary.md plus wire/fields.ts.
|
||||||
|
|
||||||
|
## 5. The hard parts that stay hard
|
||||||
|
|
||||||
|
Each hard part has an `Invariant:` paragraph above the class or function that owns it. AGENTS.md
|
||||||
|
gains a "Hard parts" index of one line per entry, giving the file and the invariant.
|
||||||
|
|
||||||
|
1. **Exactly one answer, with multipart answered on arrival** (link/answers.ts, inbound.ts). A relaying
|
||||||
|
SMSC waits for each segment's answer, so segments are answered before the whole message exists, and
|
||||||
|
the handler's answer then has nothing left to write. All of it is in two adjacent files; the ledger
|
||||||
|
refuses the second answer loudly.
|
||||||
|
2. **Retry only what was never written** (session/outbound.ts). This is goal 2's "never re-send what
|
||||||
|
the peer may have taken". The loop: `boundLink()`, then a slot, then `link.write()`. An `unwritten`
|
||||||
|
result from a gone Link loops back to `boundLink()`. A written request that fails is
|
||||||
|
`UnansweredError`. The loop reads no link predicates; the Link's own result says what happened.
|
||||||
|
3. **The drain** (session/shutdown.ts): handlers first (answering can emit a receipt), then the window,
|
||||||
|
under one deadline. `shutdownTimeout: 0` never makes the handler wait forever. It is one function,
|
||||||
|
with the order and each budget commented once.
|
||||||
|
4. **The link handover** (session/session.ts `adopt(link)` / `onGone(link, reason)`). This is the only
|
||||||
|
place where `current` changes. The link-wait releases on `adopt`; `disconnected` or `close` is
|
||||||
|
emitted last.
|
||||||
|
5. **Backoff reset only after a link has outlasted `maxDelay`** (session/reconnect.ts). The existing
|
||||||
|
decision is linked from there.
|
||||||
|
6. **Bounded stores under pressure** (bounded-store.ts, and each owner's drop policy). Reassembly now
|
||||||
|
refuses a segment that would force its own group out, where today it evicts that group; the peer
|
||||||
|
keeps and retries it. The eviction of older groups is reported as lost traffic through onDrop.
|
||||||
|
7. **The segment budget** (text/split.ts): 153 GSM septets unpacked versus 134 octets, as AGENTS.md
|
||||||
|
explains.
|
||||||
|
8. **data_coding and esm_class** (wire/fields.ts): coding groups and message classes, named and
|
||||||
|
glossed.
|
||||||
|
|
||||||
|
## 6. Build order
|
||||||
|
|
||||||
|
Each chunk is green on its own and is one PR.
|
||||||
|
|
||||||
|
1. **Words first, with no behaviour change.** docs/glossary.md, a citation sweep, wire/fields.ts,
|
||||||
|
defaults.ts, and `gsm7`/`spend` renames. Re-run the panel cheaply on this alone to learn how much
|
||||||
|
Self-sufficiency moves.
|
||||||
|
2. **BoundedStore replaces ExpiringGroups.** Port Reassembler, DlrMerger and HeldMessages to it; tests
|
||||||
|
for the own-entry refusal.
|
||||||
|
3. **Mechanical move into wire/, text/, receipts/.** Imports only, plus the types.ts and tlvs.ts
|
||||||
|
splits.
|
||||||
|
4. **The contract.** `onSms` option, link/answers.ts, link/handlers.ts, session/sms.ts; `sms` event and
|
||||||
|
`sendResp()` removed; tests and README examples ported (draft-f's `port-session-test.py` and API map
|
||||||
|
help here); the no-handler decision recorded.
|
||||||
|
5. **Link.** link/link.ts owns the per-socket state (transport, timers, pending, bind, answers,
|
||||||
|
handlers, reassembly); Session gets `life`, `current`, `lifetime`, `adopt`/`onGone`; LinkLife is
|
||||||
|
deleted; reconnect.ts reads the signal.
|
||||||
|
6. **One request path.** session/outbound.ts with lanes, link-wait.ts, shutdown.ts.
|
||||||
|
7. **Client and server folders**, then the docs pass: README (Receive SMS, Server, Shutdown), MIGRATION,
|
||||||
|
CHANGELOG, decisions (retire LinkLife-era entries, add the onSms and ledger decisions), and the
|
||||||
|
AGENTS.md architecture and hard-parts index.
|
||||||
|
8. **Panel.**
|
||||||
|
|
||||||
|
## 7. Predicted panel risks
|
||||||
|
|
||||||
|
- **Session versus Link vocabulary.** A junior may not see why the public thing is a "session" and the
|
||||||
|
inner one a "link". The glossary defines both, and link/link.ts's invariant paragraph says "one
|
||||||
|
socket; a Session outlives many". It could still cost a Navigation point.
|
||||||
|
- **session.ts stays the biggest hub.** It composes link-wait, window, reconnect, merger and the
|
||||||
|
handover, so readers will rank it hardest. The mitigation is that it holds three fields and two
|
||||||
|
transition methods; the target is under 250 lines.
|
||||||
|
- **LinkOwner is a callback interface.** E's lesson was that meaning split across callbacks in another
|
||||||
|
file is hard. The mitigation is five callbacks, each with one line in link.ts, all implemented
|
||||||
|
side by side in session.ts. A seat may still call it indirection.
|
||||||
|
- **Lanes in outbound.ts.** Four lanes are still four rules, but they are in one table; round 2 cited
|
||||||
|
lanes spread over methods.
|
||||||
|
- **The sendDlr rule is a friction point** for test-double servers that want to report immediately.
|
||||||
|
The README must show the pattern (report after the handler returns), or seniors will call it a trap.
|
||||||
|
- **The public name 'ASCII'** still says ASCII for GSM 03.38. Renaming it is a breaking change this
|
||||||
|
plan does not take; alphabets.ts carries the one-line mapping.
|
||||||
|
- **The new own-entry refusal policy** is a behaviour change under pressure. It needs a test and a
|
||||||
|
decision entry, or the architect seat will flag it as unreasoned.
|
||||||
|
- **The scale is coarse.** Chunks 1–3 may lift juniors' Self-sufficiency to 6 and nothing else. The
|
||||||
|
full point needs chunks 4–5 to lift Locality to 6 in every seat, and to 7 in two.
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# Plan 3: the newcomer's lens
|
||||||
|
|
||||||
|
A reader who has never opened the SMPP spec reads `protocol/` and learns the protocol from plain-English
|
||||||
|
types; a reader who knows it reads `session/` and holds the whole machinery at once. Builds on F (one
|
||||||
|
socket per `Session`, reconnect above it, `onSms` answered on return) and removes what F's panel still
|
||||||
|
named: `ExpiringGroups`, "exactly one answer" split over two files, and the junior's missing SMPP.
|
||||||
|
|
||||||
|
## 1. Principles
|
||||||
|
|
||||||
|
1. **Every octet that packs several facts is translated once, in `protocol/`, into a named plain type.**
|
||||||
|
`esm_class`, `data_coding`, `registered_delivery`, the UDH, `sar_*` and `message_state` are read and
|
||||||
|
written there and nowhere else; `session/` and `messages/` never see a bit mask. Answers: juniors at
|
||||||
|
Self-sufficiency 5 (masks, bare citations), "no glossary".
|
||||||
|
2. **The vocabulary is code.** `protocol/vocabulary.ts` holds one type per SMPP term (ESME/SMSC,
|
||||||
|
bind type, alphabet, message kind, segment, TLV, status), each with a one-line TSDoc definition the
|
||||||
|
editor shows on hover. A README glossary did not lift juniors; a definition beside the use does.
|
||||||
|
3. **A spec citation always carries its sentence.** `SMPP 3.4 §5.2.12 (esm_class): bits 5-2 say whether
|
||||||
|
this is a message or a receipt.` A test greps `src/` and fails on a bare `§x.y.z`. Answers: "citations
|
||||||
|
with no summary".
|
||||||
|
4. **A `Session` is one socket, and its life only moves forward.** `open → bound → closing → closed`,
|
||||||
|
one field, one transition function, no flag beside it. Reconnect lives above, in `SmppClient`, which
|
||||||
|
holds only what outlives a socket. Answers lesson 3 (LinkLife, seven predicates, call-order
|
||||||
|
correctness, duplicated stopped flags); F showed it takes the lifecycle off the hardest-unit list.
|
||||||
|
5. **An answer is a return value, and one function writes it.** Every inbound request resolves to one
|
||||||
|
`Reply`; `session.ts` writes it in one place. `onSms` and `onRequest` return replies instead of
|
||||||
|
calling a sender. Answers F's "exactly one answer enforced jointly by IncomingRequests and sms.ts",
|
||||||
|
and lesson 4's callbacks into `Session`.
|
||||||
|
6. **A bounded store refuses; it never evicts.** One `BoundedStore` enforces its own count, weight and
|
||||||
|
expiry; reads never mutate; the only removal a caller did not ask for is expiry, reported through
|
||||||
|
one callback. A full store refuses the newcomer, and the peer retries it (goal 2: nothing the peer
|
||||||
|
will not resend is dropped). Answers `ExpiringGroups`/`Reassembler.trim` (4 of 8 F seats).
|
||||||
|
7. **Every default is one row in one table.** `options.ts`. Answers "defaults spread over several files".
|
||||||
|
8. **Names say the domain, not the implementation.** GSM 7-bit is `gsm7` everywhere; `DlrMerger.close`
|
||||||
|
becomes `spend`; no `ascii`.
|
||||||
|
|
||||||
|
## 2. Layout
|
||||||
|
|
||||||
|
Areas, in reading order: `protocol/` (what SMPP means), `codec/` (bytes ↔ objects), `messages/`
|
||||||
|
(whole messages), `session/` (one socket), `client/`, `server/`. Imports point down that list in
|
||||||
|
reverse: `codec` ← `protocol` ← `messages` ← `session` ← `client`/`server`; `protocol` knows `codec`'s
|
||||||
|
tables only. Fan-out: root 10 (4 files, 6 dirs); `codec/` 8; `protocol/` 10; `messages/` 8;
|
||||||
|
`session/` 7; `client/` 3; `server/` 2. No file over ~300 lines except `codec/field-types.ts`.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
index.ts Public surface, named exports only.
|
||||||
|
options.ts `defaults`: every default and internal cap, one row each with its why; the
|
||||||
|
option checks for client(), server() and new Session(). No state.
|
||||||
|
result.ts Result<T>, VoidResult, errorFrom(), namedValue(). No state.
|
||||||
|
log.ts SmppLog, silentLog, guardedLog(). No state.
|
||||||
|
codec/ Bytes <-> PduObject. Knows field layout, never meaning.
|
||||||
|
commands.ts The 33 commands, ids, params in wire order (invariant: never sorted).
|
||||||
|
tlvs.ts TLV table by id, typed read/input shapes, read/write a TLV stream (exact to
|
||||||
|
command_length, one NULL pad tolerated).
|
||||||
|
statuses.ts command_status table (ESME_*), by id.
|
||||||
|
constants.ts Raw numeric tables (`consts`), exported as-is; meaning lives in protocol/.
|
||||||
|
field-types.ts int8/16/32, C-Octet and Octet strings, buffers, address arrays; range-checked.
|
||||||
|
pdu.ts pduToObj / objToPdu / pduReturn / isCommand / isResp. Synchronous, total.
|
||||||
|
framer.ts PduFramer: a byte stream cut into PDUs; state: the chunk list.
|
||||||
|
refusal.ts PduRefusedError, framing refusal, the status a refused PDU is answered with.
|
||||||
|
protocol/ What the fields mean. Plain types out, SMPP octets in. No state.
|
||||||
|
vocabulary.ts One type per SMPP term with its one-line definition: LinkEnd (ESME/SMSC),
|
||||||
|
BindType, Alphabet, MessageKind, Segment, MessageState, Reply. The glossary.
|
||||||
|
alphabets.ts The gsm7, latin1 and ucs2 codecs; detect(); unencodable(); bitCount().
|
||||||
|
data-coding.ts data_coding as a table of rows {octets, alphabet, messageClass, why}; read
|
||||||
|
and write through the table, never a mask outside it. Flash lives here.
|
||||||
|
esm-class.ts esm_class -> {kind: message|receipt|intermediate, hasUdh, mode} and back.
|
||||||
|
segments.ts How a PDU says it is a segment: the UDH (walked, with its reference) or sar_*,
|
||||||
|
and the reference counter per client (state: one counter).
|
||||||
|
receipt.ts Receipt text and TLVs -> Dlr (stat: spellings, FAILED, DELIVERD, final or
|
||||||
|
not), and a receipt's deliver_sm params for a state.
|
||||||
|
message-ids.ts Id notations (smsIdFormat), <base>-<n> segment ids, which response carries one.
|
||||||
|
time.ts smppTime (absolute/relative) and the receipt's YYMMDDhhmm stamp.
|
||||||
|
bind.ts What a bind direction carries, data_sm's stand-in, the version that allows
|
||||||
|
optional params, checkedBind().
|
||||||
|
arrival.ts One inbound message-carrying PDU -> Arrival: {kind:'message', text, from, to,
|
||||||
|
segment?, wantsReceipt, flash} | {kind:'receipt', dlr}. Body location
|
||||||
|
(short_message vs message_payload) decided here.
|
||||||
|
messages/ Whole messages across segments and time.
|
||||||
|
split.ts Text -> segment bodies at the 153-septet / 134-octet budget (invariant: GSM is
|
||||||
|
sent unpacked; see AGENTS.md).
|
||||||
|
submit.ts SendSmsOptions checked, then one submit_sm params object per segment; all
|
||||||
|
refusals before any segment exists.
|
||||||
|
bounded-store.ts BoundedStore<T>: count cap, weight cap, per-entry deadline. add() returns
|
||||||
|
added|full; get() is pure; expiry on its own timer, reported via onExpire.
|
||||||
|
reassembly.ts Reassembler: segment groups in two reference spaces (udh, sar); add returns
|
||||||
|
kept|unplaceable|full|whole. State: one BoundedStore.
|
||||||
|
receipt-merge.ts ReceiptMerge: per-segment Dlrs -> one MessageDlr; a base merged once (spent).
|
||||||
|
State: two BoundedStores (open, spent).
|
||||||
|
retained.ts detach() a PDU off its chunk; retainedOctets() for weights.
|
||||||
|
session/ One socket's life. State lives in session.ts, requests-out.ts, handlers.ts.
|
||||||
|
session.ts Session: lifecycle field + transition table, the event surface, admit() for
|
||||||
|
sends, write() for replies — the only writer of responses. ~250 lines.
|
||||||
|
requests-out.ts OutgoingRequests: sequence numbers, pending map, send window, response
|
||||||
|
timeout, abort. One entry point. Reports written-or-not on failure.
|
||||||
|
requests-in.ts replyFor(pdu, context) -> {reply?, after?: 'close'}: bind direction,
|
||||||
|
enquire_link, unbind, receipts, messages, unknown commands. No state, no
|
||||||
|
callbacks into Session.
|
||||||
|
handlers.ts RunningHandlers: onSms calls in flight; count/weight bound; handlerTimeout
|
||||||
|
answers with the retry status; idle() for the drain.
|
||||||
|
keepalive.ts enquire_link on a quiet link, idle timeout. State: two timers.
|
||||||
|
transport.ts Socket -> framed, parsed PDUs; refusals; the socket is fixed for life.
|
||||||
|
waiting.ts waitUntil(predicate, budget, signal) and leftOf(): the abort dance, once.
|
||||||
|
client/
|
||||||
|
client.ts SmppClient: current session, cross-link state (ReceiptMerge, segment
|
||||||
|
reference counter), request loop that waits for a bound session and retries
|
||||||
|
only an unwritten request; re-emits session events; close()/unbind().
|
||||||
|
connect.ts openSocket (net/TLS, connectTimeout) and bind(); returns a bound Session.
|
||||||
|
backoff.ts Backoff: next delay, reset after a link outlasts maxDelay. No timers, no stop flag.
|
||||||
|
server/
|
||||||
|
server.ts SmppServer, server(): listener, live sessions, close() drains all.
|
||||||
|
accept-bind.ts authenticate, record the bind, bind_resp; before-bind refusals.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Public API changes
|
||||||
|
|
||||||
|
Six, all in the minor that ships this (pre-1.0: the minor is the breaking unit). Each lands in
|
||||||
|
MIGRATION.md; the goal it rests on is recorded in docs/decisions.md.
|
||||||
|
|
||||||
|
| # | Old | New | Removes | Goal |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A1 | `client()` → `{ err, session }`, one `Session` reconnecting under you | `{ err, client }`, an `SmppClient` with `sendSms/send/close/unbind`, events `close/disconnected/reconnected/dlr/messageDlr/sessionError`; `client.session` is the current bound `Session` or `undefined` | Lifecycle as the hardest unit: LinkLife, 7 predicates, re-entrant close, two stopped flags, bindOn's cross-file ordering | 5, 8 |
|
||||||
|
| A2 | `session.on('sms', sms => sms.sendResp(opts))`; drain waits on unanswered `sms` | `onSms: sms => Reply \| void` option on `client()`, `server()`, `new Session()`; return answers (`ESME_ROK`, or `{ smsId }`, or `{ status }`); throw/reject answers the retry status and reports on `sessionError`; no `onSms` answers the retry status; `sendResp()` removed | Held-message timing contract (six exits, setImmediate, listener counts, WeakMap) and "answered in three places" | 2, 5 |
|
||||||
|
| A3 | `await sms.sendResp(); await sms.sendDlr()` in the listener | `onSms` may return `{ dlr: MessageState }`: the receipt goes out right after the answer; `sms.sendDlr()` stays for later reports and returns `err` before the answer is written | The "sendDlr let past the drain straight after sendResp" rule; a receipt can no longer precede its answer, and awaiting one inside the handler cannot deadlock | 2 |
|
||||||
|
| A4 | `onRequest: (s, pdu) => boolean`, answering via `session.sendReturn()` | `onRequest: (s, pdu) => Reply \| undefined`; `undefined` = built-in handling; `sendReturn()` removed from `Session` (`pduReturn()` stays in the codec) | The second answering path; makes "one answer per request" a type | 2, 7 |
|
||||||
|
| A5 | `new Session({ reconnect, ... })`, mutable `session.linkEnd` | no `reconnect`; `linkEnd` option, readonly; `disconnected`/`reconnected`/`messageDlr` only on `SmppClient` | Reconnect state inside the one-socket unit | 8 |
|
||||||
|
| A6 | `encoding: 'ASCII'` | `encoding: 'GSM7'`; `'ASCII'` refused at option check with "use 'GSM7'" | A junior reading "ASCII" for GSM 03.38 | 1 (a wrong name invites wrong data), 8 |
|
||||||
|
|
||||||
|
Unchanged on purpose: the codec exports, `sendSms()` options and result, `Dlr`/`MessageDlr`, `server()`
|
||||||
|
options, `sessionError`/`serverError`, `PduRefusedError`. Behaviour change without a signature change:
|
||||||
|
the reassembly and receipt-merge stores refuse at their bound instead of evicting the oldest (segment:
|
||||||
|
`ESME_RTHROTTLED`/`ESME_RX_T_APPN`; merge: that send reports through `dlr` alone). Recorded as a
|
||||||
|
decision on goal 2, which it serves better than eviction did (an evicted group is answered traffic lost).
|
||||||
|
A6 is the cheapest to drop if the board wants fewer breaks; the internal rename happens either way.
|
||||||
|
|
||||||
|
## 4. Where each thing goes
|
||||||
|
|
||||||
|
| Now | New home |
|
||||||
|
|---|---|
|
||||||
|
| index.ts | index.ts |
|
||||||
|
| client.ts | client/connect.ts (socket, TLS, timeout, bind), client/client.ts (fromStart, abort) |
|
||||||
|
| server.ts | server/server.ts, server/accept-bind.ts |
|
||||||
|
| session.ts | session/session.ts (lifecycle, events, write), client/client.ts (reconnect parts) |
|
||||||
|
| link-life.ts | deleted: lifecycle field in session/session.ts; "wait for a link" in client/client.ts |
|
||||||
|
| reconnect-loop.ts | client/backoff.ts (delay math) + client/client.ts (the one timer, stop = state) |
|
||||||
|
| link-timers.ts | session/keepalive.ts |
|
||||||
|
| pdu-transport.ts | session/transport.ts (no attach(): one socket) |
|
||||||
|
| outgoing-requests.ts, pending-requests.ts, send-window.ts, unanswered-error.ts | session/requests-out.ts (one entry point; UnansweredError kept, exported type unchanged) |
|
||||||
|
| incoming-requests.ts | session/requests-in.ts (returns replies) |
|
||||||
|
| held-messages.ts | deleted: session/handlers.ts counts running handlers |
|
||||||
|
| idle-waiters.ts | session/waiting.ts |
|
||||||
|
| sms.ts | session/handlers.ts builds the Sms; its fields come from protocol/arrival.ts; sendDlr params from protocol/receipt.ts |
|
||||||
|
| expiring-groups.ts | messages/bounded-store.ts |
|
||||||
|
| reassembly.ts | messages/reassembly.ts |
|
||||||
|
| dlr-merger.ts | messages/receipt-merge.ts (close → spend) |
|
||||||
|
| dlr.ts | protocol/receipt.ts |
|
||||||
|
| concat.ts, udh.ts | protocol/segments.ts |
|
||||||
|
| message-body.ts | protocol/arrival.ts |
|
||||||
|
| message.ts | messages/split.ts (split, budgets), protocol/alphabets.ts (encode/decode/bitCount), protocol/time.ts |
|
||||||
|
| send-sms.ts | messages/submit.ts |
|
||||||
|
| sms-id.ts | protocol/message-ids.ts |
|
||||||
|
| session-options.ts | options.ts (defaults, checks), protocol/bind.ts (directions, stand-in), session/session.ts (events type) |
|
||||||
|
| retained-pdu.ts | messages/retained.ts |
|
||||||
|
| pdu.ts, pdu-framer.ts, pdu-refusal.ts | codec/pdu.ts, codec/framer.ts, codec/refusal.ts |
|
||||||
|
| defs/commands, tlvs, errors, types, constants | codec/commands, tlvs, statuses, field-types, constants |
|
||||||
|
| defs/encodings.ts | protocol/alphabets.ts + protocol/data-coding.ts |
|
||||||
|
| defs/index.ts | deleted; `defs` assembled in index.ts |
|
||||||
|
| error-from.ts, result.ts | result.ts |
|
||||||
|
| log.ts, uuid.ts | log.ts; uuid.ts → protocol/message-ids.ts |
|
||||||
|
|
||||||
|
| Hard responsibility | Home |
|
||||||
|
|---|---|
|
||||||
|
| Held messages and answering | session/requests-in.ts decides the reply; session/handlers.ts runs onSms and turns its outcome into a Reply; session/session.ts write() is the one writer |
|
||||||
|
| Lifecycle | session/session.ts: 4 states, forward only, `close` emitted on entering `closed` |
|
||||||
|
| Reconnect | client/client.ts (loop, current session), client/backoff.ts (delays) |
|
||||||
|
| The drain | session/session.ts `close()`: state → closing, `handlers.idle(budget)`, then `requests.idle(rest)`, then closed |
|
||||||
|
| Outgoing requests and retry | session/requests-out.ts (one link, no retry); client/client.ts (retry on the next link only when unwritten) |
|
||||||
|
| Reassembly, store | messages/reassembly.ts over messages/bounded-store.ts |
|
||||||
|
| Receipts and merging | protocol/receipt.ts (reading/writing), messages/receipt-merge.ts (merging), client/client.ts (owns the merge across links) |
|
||||||
|
| Codec | codec/ |
|
||||||
|
| Encodings | protocol/alphabets.ts, protocol/data-coding.ts |
|
||||||
|
| Defaults | options.ts |
|
||||||
|
| Domain knowledge, glossary | protocol/vocabulary.ts and the rest of protocol/ |
|
||||||
|
|
||||||
|
## 5. The hard parts that stay hard
|
||||||
|
|
||||||
|
Each is marked by one invariant paragraph at the top of the function it guards (the one comment
|
||||||
|
exception AGENTS allows), and named in AGENTS.md's architecture list as "hard".
|
||||||
|
|
||||||
|
1. **Multipart is answered on arrival, a whole message on return** (decision: a relaying SMSC waits per
|
||||||
|
segment). One function, `requests-in.ts replyFor()`, holds both branches; `Sms.answeredOnArrival`
|
||||||
|
stays; a Reply refusing an arrival-answered message goes to `sessionError`.
|
||||||
|
2. **Segment grouping**: two reference spaces, inconsistent totals, the refusal status per spelling.
|
||||||
|
`messages/reassembly.ts` only; the store underneath is dumb.
|
||||||
|
3. **The drain's two budgets** (handlers ignore `shutdownTimeout: 0`, requests do not).
|
||||||
|
`session.ts close()`, one function, both budgets computed in one place from `options.ts`.
|
||||||
|
4. **Retry only what never reached the socket** (goal 2). `client.ts request()`, one loop, reading only
|
||||||
|
`result.written`; no link state consulted mid-await because the session it used is fixed.
|
||||||
|
5. **data_coding**: coding groups, class bits, 0x01 read as GSM. Becomes a readable table in
|
||||||
|
`protocol/data-coding.ts`, with a test that the table equals today's function on all 256 octets.
|
||||||
|
6. **Codec strictness**: TLV stream exact to `command_length`, one NULL pad, 32-bit seqNr echo.
|
||||||
|
`codec/pdu.ts` and `codec/tlvs.ts`.
|
||||||
|
7. **`fromStart` + abort**: `client/client.ts client()` only; the loop's stop is the client's state
|
||||||
|
`closed`, so no ordering promise crosses files.
|
||||||
|
|
||||||
|
## 6. Build order
|
||||||
|
|
||||||
|
The order that runs is todo.md's, amended by §8.
|
||||||
|
|
||||||
|
## 7. Predicted panel risks
|
||||||
|
|
||||||
|
- **Two send surfaces.** `SmppClient.sendSms()` and `Session.sendSms()` look alike; a reader asks
|
||||||
|
which to call. Mitigation: the Session's is the one-link primitive, documented as such; still likely
|
||||||
|
a Navigation point.
|
||||||
|
- **`Reply` carrying `dlr`** is a second way to send a receipt beside `sms.sendDlr()`. Different
|
||||||
|
results (with the answer vs later), but a strict reader may call it two spellings.
|
||||||
|
- **Refuse-not-evict**: a peer that abandons many groups blocks new multipart for `reassemblyTimeout`.
|
||||||
|
A senior may call that an operator-facing regression (goal 4) and score Shape down.
|
||||||
|
- **protocol/ vs requests-in.ts**: "is it a receipt" (arrival.ts) and "what do we answer"
|
||||||
|
(requests-in.ts) are split by design; a junior may look for both in one place.
|
||||||
|
- **codec/field-types.ts** stays ~650 dense lines; it was never the named unit, but a junior reading
|
||||||
|
it cold still scores Self-sufficiency down unless its citations carry their sentences too.
|
||||||
|
- **Test suite size**: porting ~all session tests is the real cost; a half-ported suite hides
|
||||||
|
regressions that a panel will not see but goal 1 will.
|
||||||
|
- **The coarse scale**: even if every named unit is fixed, a mean of 7.0 needs all four seats to move,
|
||||||
|
and the hardest-unit list has moved every round; expect a new one (likely requests-in.ts or
|
||||||
|
SmppClient's request loop) at 6.
|
||||||
|
|
||||||
|
## 8. Architecture review, 2026-09-30
|
||||||
|
|
||||||
|
Verdict ALIGN: the direction serves the goals, but §6 could not leave the suite green and the plan
|
||||||
|
overturned recorded decisions without naming them. todo.md carries the reordered build; the rest:
|
||||||
|
|
||||||
|
1. **Each public API row names the decision it replaces**, and the replacement lands in
|
||||||
|
docs/decisions.md in the chunk that makes it. Overturned without saying so: `src/` stays flat;
|
||||||
|
`Session` publicly constructible (`ReconnectOptions` moves); both emitters re-declare listeners
|
||||||
|
(`SmppClient` is a third); every segment answered on arrival (a refused `smsId` becomes a
|
||||||
|
`sessionError`); `server()` composes `onRequest` (A4 is its rejected alternative); the drain waits
|
||||||
|
on held messages, capped on constants; `linkEnd` beside `boundAs` (A5 makes it an option); bind state
|
||||||
|
holds through the gap, and one owner decides whether a link carries a request; `close` means over;
|
||||||
|
a receipt does not belong to the link; a base merged once, capped like the groups; the total
|
||||||
|
encoding signatures and GSM declaring 0x00 (A6 renames `EncodingName`, so the codec exports do
|
||||||
|
change); the abort dance copied, not extracted (`waiting.ts`: recount the sites, then keep the
|
||||||
|
copies or revise the decision).
|
||||||
|
2. **The segment reference counter is `SmppClient` state**, passed to `messages/submit.ts`; `protocol/`
|
||||||
|
holds none.
|
||||||
|
3. **`client/next-link.ts`** bounds the wait by `responseTimeout` from when the send was issued,
|
||||||
|
builds the PDU against the session it lands on (retiring "bind state holds through the gap"), fails
|
||||||
|
waiting requests as unwritten on `close()`/`unbind()`, and registers a multipart send's merge before
|
||||||
|
its segments go out.
|
||||||
|
4. **`SmppClient` re-emits every `Session` event but `sms` and `close`**; a session's `close` is
|
||||||
|
`disconnected` unless the client is over. `client.session` is the current link, and a listener on it
|
||||||
|
lasts one link.
|
||||||
|
5. **`sms.sendDlr()` goes through a receipt sender `handlers.ts` is given**: `SmppClient`'s next-link
|
||||||
|
path in client mode. The answer stays on the arrival session, which `Sms.session` names.
|
||||||
|
6. **One function in `session.ts` owns the async answer**: `replyFor()`, then `handlers.run(sms)` where
|
||||||
|
the application decides, then `write()`. `handlers.ts` returns a `Promise<Reply>` and never calls
|
||||||
|
into `Session`.
|
||||||
|
7. **`BoundedStore` is internal**, not goal 9's store interface; `ReceiptMerge` records stay plain data.
|
||||||
|
Reassembly refuses at its bound: an evicted group is answered segments lost, goal 2 outranks goal 4,
|
||||||
|
and the store is per session. The spent set expires by age.
|
||||||
|
8. **`retained.ts` goes to `codec/`**; `options.ts` joins AGENTS.md's type-only ways back up.
|
||||||
|
9. **A file keeps its export's name until the chunk that renames the export.** The scoring run of
|
||||||
|
2026-09-30 read §2's names on unrenamed classes (`keepalive.ts` holding `LinkTimers`) as lies, so
|
||||||
|
§2 and §4 name where a file ends, not what it is called before its export changes.
|
||||||
|
|
||||||
|
Public API questions, answered as the review recommends. Maintainer's call, 2026-09-30; each
|
||||||
|
lands in docs/decisions.md with the chunk that builds it:
|
||||||
|
|
||||||
|
- **Q1 (A1).** `client()` returns `{ err, client }`? Yes: the returned type changes anyway.
|
||||||
|
- **Q2 (A3).** `onSms` may return a receipt state? Yes, against the board: without it "answer, then
|
||||||
|
report at once" has no correct spelling, and the two differ in result, so they are not two spellings.
|
||||||
|
- **Q3 (A4).** `onRequest`'s `Reply`: any status, `params` and `tlvs`, or an explicit no-answer, async
|
||||||
|
allowed. Narrower cannot answer a bind, a vendor command or a `data_sm`.
|
||||||
|
- **Q4 (A6).** `'ASCII'` becomes `'GSM7'` in every export? Yes: dropping it keeps two names for one
|
||||||
|
alphabet at the boundary.
|
||||||
|
- **Q5.** `sendSms()` and `messageDlr` on `SmppClient` only, `Session` keeping `send()`? Yes: one send
|
||||||
|
surface and one counter; a hand-wired `Session` loses `sendSms()`.
|
||||||
|
- **Q6.** No `onSms` answers the retry status, with a warning once per session? Yes, goal 2 over goal 4.
|
||||||
|
- **Q7.** `handlerTimeout` a constant, answering the retry status on expiry, a late `Reply` on
|
||||||
|
`sessionError`, the drain waiting at most that long? Yes.
|
||||||
|
- **Q8.** A full merge store: register before sending and say in `SendSmsResult` that no merged report
|
||||||
|
follows? Yes, over oldest-eviction for merges or a silent refusal.
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
# 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
|
||||||
|
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.** `bound()`
|
||||||
|
records what the peer declared, the ESME's `interface_version` or the SMSC's
|
||||||
|
`sc_interface_version`; 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 `protocol/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, which goal 4 forbids.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
- **`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 `protocol/message-ids.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, 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 `LinkLife`'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 link down, so one budget covers
|
||||||
|
every hold a single call makes.
|
||||||
|
|
||||||
|
- **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 while held for a link 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.
|
||||||
|
|
||||||
|
- **One owner decides whether a link can carry a request, and a bind is what makes it one.**
|
||||||
|
Maintainer's call, 2026-09-01, extended 2026-09-28; goal 1, since a send on a link not yet bound
|
||||||
|
comes back `ESME_RINVBNDSTS`. `LinkLife` is told what happened and never reads back into the
|
||||||
|
session; every other collaborator reads it and keeps no copy. Rejected: gating on the socket being
|
||||||
|
attached, which admits a send one round trip before the bind is answered, and collaborators that
|
||||||
|
ask the session, which answered the same question two ways at admit and at release.
|
||||||
|
`ReconnectLoop.halted` is the one other flag, because `client()` also runs a loop with no session
|
||||||
|
behind it for `fromStart`; a session's loop is stopped by `Session.stop()` alone.
|
||||||
|
|
||||||
|
|
||||||
|
## Internals and tests
|
||||||
|
|
||||||
|
- **Locality work comes before other work until a scoring run reads 7.0.** Maintainer's call,
|
||||||
|
2026-09-27, when #30 merged under the comprehension floor at 6, 6, 7 and 6; #46, #48 and #49
|
||||||
|
merged under it on that condition, #49 at 6, 6, 7 and 6 with Locality 5, 5, 6 and 6. 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 `LinkLife`, `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/` is grouped by layer, and imports point down the layers.** Maintainer's call, 2026-09-30,
|
||||||
|
with the Locality rewrite; the [map](../AGENTS.md#architecture) names the order and places each
|
||||||
|
root file in a layer. Serves goal 8: internals are reshapeable only once a reader can find them,
|
||||||
|
and every comprehension panel navigated by the map. Rejected: `src/` flat until a module has to
|
||||||
|
move for another reason. Valid while the map is what readers navigate by.
|
||||||
|
|
||||||
|
- **`test/` stays flat, 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.
|
||||||
+3
-3
@@ -35,17 +35,17 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The spec tables are data: their length tracks the specification, not any complexity.
|
// The spec tables are data: their length tracks the specification, not any complexity.
|
||||||
files: ['src/defs/*.ts'],
|
files: ['src/codec/{commands,constants,encodings,errors,tlvs,types}.ts'],
|
||||||
rules: { 'max-lines': 'off' },
|
rules: { 'max-lines': 'off' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// ESLint counts every ?. and ?? in dlrFromPdu as a branch; the 19 is 26 lines of flat field resolution.
|
// ESLint counts every ?. and ?? in dlrFromPdu as a branch; the 19 is 26 lines of flat field resolution.
|
||||||
files: ['src/dlr.ts'],
|
files: ['src/protocol/dlr.ts'],
|
||||||
rules: { complexity: ['error', 19] },
|
rules: { complexity: ['error', 19] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns.
|
// ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns.
|
||||||
files: ['src/defs/encodings.ts'],
|
files: ['src/codec/encodings.ts'],
|
||||||
rules: { 'no-control-regex': 'off' },
|
rules: { 'no-control-regex': 'off' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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/` |
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import test, { after, describe } from 'node:test';
|
import test, { after, describe } from 'node:test';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import type { SmppServer } from '../src/server.ts';
|
import type { SmppServer } from '../src/server/server.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
|
|
||||||
const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080';
|
const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080';
|
||||||
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
|||||||
@@ -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, 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
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test, { after, describe } from 'node:test';
|
import test, { after, describe } from 'node:test';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import type { LogMethod, SmppLog } from '../src/log.ts';
|
import type { LogMethod, SmppLog } from '../src/log.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
|
|
||||||
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
/** Slower than every scenario's submission rate (2000/s for the window runs), so a real backlog
|
/** Slower than every scenario's submission rate (2000/s for the window runs), so a real backlog
|
||||||
@@ -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, 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,7 +270,7 @@ describe('S6 - idle peer, no enquire_link at all', () => {
|
|||||||
assert.ok(bound, 'dumb-idle never submitted its one message');
|
assert.ok(bound, 'dumb-idle never submitted its one message');
|
||||||
|
|
||||||
// idleTimeout is 40s from the last byte the peer sent (its submit_sm), never from our own
|
// idleTimeout is 40s from the last byte the peer sent (its submit_sm), never from our own
|
||||||
// writes (link-timers.ts resets only on inbound data). This test may start running well
|
// writes (session/link-timers.ts resets only on inbound data). This test may start running well
|
||||||
// past that mark on its own (S9 above can take a minute) - statsFor(...).closed is set from
|
// past that mark on its own (S9 above can take a minute) - statsFor(...).closed is set from
|
||||||
// a 'close' listener attached at session-creation time, so a close from before this test
|
// a 'close' listener attached at session-creation time, so a close from before this test
|
||||||
// even started is still seen; budget is slack for a session that is still open, not a clock.
|
// even started is still seen; budget is slack for a session that is still open, not a clock.
|
||||||
@@ -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).
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import test, { after, describe } from 'node:test';
|
import test, { after, describe } from 'node:test';
|
||||||
import type { Dlr } from '../src/dlr.ts';
|
import type { Dlr } from '../src/protocol/dlr.ts';
|
||||||
import type { EncodingName } from '../src/defs/encodings.ts';
|
import type { EncodingName } from '../src/codec/encodings.ts';
|
||||||
import type { PduObject } from '../src/pdu.ts';
|
import type { PduObject } from '../src/codec/pdu.ts';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import { ConcatReference } from '../src/udh.ts';
|
import { ConcatReference } from '../src/protocol/udh.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client/client.ts';
|
||||||
import { closeAfter } from '../test/teardown.ts';
|
import { closeAfter } from '../test/teardown.ts';
|
||||||
import { paramText } from '../src/defs/types.ts';
|
import { paramText } from '../src/codec/types.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
import { encodeMessage, splitMessage } from '../src/message.ts';
|
import { encodeMessage, splitMessage } from '../src/message.ts';
|
||||||
import { submitSmParams } from '../src/send-sms.ts';
|
import { submitSmParams } from '../src/messages/submit.ts';
|
||||||
|
|
||||||
const PEER_HOST = process.env.PEER_HOST ?? 'jasmin';
|
const PEER_HOST = process.env.PEER_HOST ?? 'jasmin';
|
||||||
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import test, { after, describe } from 'node:test';
|
import test, { after, describe } from 'node:test';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import { PduRefusedError } from '../src/index.ts';
|
import { PduRefusedError } from '../src/index.ts';
|
||||||
import { bareTlvHeader, pduBytes } from '../test/raw-pdus.ts';
|
import { bareTlvHeader, pduBytes } from '../test/raw-pdus.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
|
|
||||||
const JSMPP_HOST = process.env.JSMPP_HOST ?? 'jsmpp:8080';
|
const JSMPP_HOST = process.env.JSMPP_HOST ?? 'jsmpp:8080';
|
||||||
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import test, { after, describe } from 'node:test';
|
import test, { after, describe } from 'node:test';
|
||||||
import type { MessageState } from '../src/defs/constants.ts';
|
import type { MessageState } from '../src/codec/constants.ts';
|
||||||
import type { Dlr } from '../src/dlr.ts';
|
import type { Dlr } from '../src/protocol/dlr.ts';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import { ConcatReference } from '../src/udh.ts';
|
import { ConcatReference } from '../src/protocol/udh.ts';
|
||||||
import { consts } from '../src/defs/constants.ts';
|
import { consts } from '../src/codec/constants.ts';
|
||||||
import { detect, encodings } from '../src/defs/encodings.ts';
|
import { detect, encodings } from '../src/codec/encodings.ts';
|
||||||
import { paramText } from '../src/defs/types.ts';
|
import { paramText } from '../src/codec/types.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
import { splitMessage } from '../src/message.ts';
|
import { splitMessage } from '../src/message.ts';
|
||||||
import { submitSmParams } from '../src/send-sms.ts';
|
import { submitSmParams } from '../src/messages/submit.ts';
|
||||||
|
|
||||||
// smsbox HTTP hosts, one per variant - all point at the same node:2775 SMPP server.
|
// smsbox HTTP hosts, one per variant - all point at the same node:2775 SMPP server.
|
||||||
const MAIN_SMSBOX = process.env.MAIN_SMSBOX ?? 'kannel-smsbox:13013';
|
const MAIN_SMSBOX = process.env.MAIN_SMSBOX ?? 'kannel-smsbox:13013';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test, { after, describe } from 'node:test';
|
import test, { after, describe } from 'node:test';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import { paramText } from '../src/defs/types.ts';
|
import { paramText } from '../src/codec/types.ts';
|
||||||
import { isCommand, server } from '../src/index.ts';
|
import { isCommand, server } from '../src/index.ts';
|
||||||
|
|
||||||
const DRIVER = process.env.PHP_DRIVER ?? 'php:8080';
|
const DRIVER = process.env.PHP_DRIVER ?? 'php:8080';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test, { after, describe } from 'node:test';
|
import test, { after, describe } from 'node:test';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import { encodings } from '../src/defs/encodings.ts';
|
import { encodings } from '../src/codec/encodings.ts';
|
||||||
import { paramText } from '../src/defs/types.ts';
|
import { paramText } from '../src/codec/types.ts';
|
||||||
import { isCommand, server } from '../src/index.ts';
|
import { isCommand, server } from '../src/index.ts';
|
||||||
|
|
||||||
const DRIVER = process.env.PYTHON_DRIVER ?? 'python:8080';
|
const DRIVER = process.env.PYTHON_DRIVER ?? 'python:8080';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test, { after, describe } from 'node:test';
|
import test, { after, describe } from 'node:test';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
|
|
||||||
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ describe('smppload (blocked)', () => {
|
|||||||
const refusal = await waitFor(() => sessionErr, 10_000);
|
const refusal = await waitFor(() => sessionErr, 10_000);
|
||||||
|
|
||||||
assert.ok(refusal);
|
assert.ok(refusal);
|
||||||
// maxPduLength (pdu-refusal.ts) is 1MiB; the corrupted command_length (0x2a shifted into the
|
// maxPduLength (codec/refusal.ts) is 1MiB; the corrupted command_length (0x2a shifted into the
|
||||||
// high bytes) reads as roughly 2.75M, so this is the "unreadable stream" teardown, not the
|
// high bytes) reads as roughly 2.75M, so this is the "unreadable stream" teardown, not the
|
||||||
// "one bad PDU, link stays up" path - see AGENTS.md, "A stream this library cannot frame...".
|
// "one bad PDU, link stays up" path - see AGENTS.md, "A stream this library cannot frame...".
|
||||||
assert.match(refusal.message, /Refusing a cmd_length of \d+/);
|
assert.match(refusal.message, /Refusing a cmd_length of \d+/);
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
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 { Dlr } from '../src/dlr.ts';
|
import type { Dlr } from '../src/protocol/dlr.ts';
|
||||||
import type { EncodingName } from '../src/defs/encodings.ts';
|
import type { EncodingName } from '../src/codec/encodings.ts';
|
||||||
import type { MessageDlr } from '../src/dlr-merger.ts';
|
import type { MessageDlr } from '../src/messages/dlr-merger.ts';
|
||||||
import type { PduObject } from '../src/pdu.ts';
|
import type { PduObject } from '../src/codec/pdu.ts';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client/client.ts';
|
||||||
import { closeAfter } from '../test/teardown.ts';
|
import { closeAfter } from '../test/teardown.ts';
|
||||||
import { consts } from '../src/defs/constants.ts';
|
import { consts } from '../src/codec/constants.ts';
|
||||||
import { paramText } from '../src/defs/types.ts';
|
import { paramText } from '../src/codec/types.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
|
|
||||||
const PEER_HOST = process.env.PEER_HOST ?? 'smppsim';
|
const PEER_HOST = process.env.PEER_HOST ?? 'smppsim';
|
||||||
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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 { Dlr } from '../src/dlr.ts';
|
import type { Dlr } from '../src/protocol/dlr.ts';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client/client.ts';
|
||||||
import { closeAfter } from '../test/teardown.ts';
|
import { closeAfter } from '../test/teardown.ts';
|
||||||
|
|
||||||
const PEER_HOST = process.env.PEER_HOST ?? 'smscsim';
|
const PEER_HOST = process.env.PEER_HOST ?? 'smscsim';
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import type { ConnectionOptions } from 'node:tls';
|
import type { ConnectionOptions } from 'node:tls';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from '../result.ts';
|
||||||
import type { BindType, ReconnectOptions } from './session-options.ts';
|
import type { BindType } from '../protocol/bind.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { ReconnectOptions } from '../options.ts';
|
||||||
import type { SmsIdFormat } from './sms-id.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
|
import type { SmsIdFormat } from '../protocol/message-ids.ts';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
export type { BindType };
|
export type { BindType };
|
||||||
|
|
||||||
import { ReconnectLoop } from './reconnect-loop.ts';
|
import { ReconnectLoop } from '../session/reconnect-loop.ts';
|
||||||
import { Session } from './session.ts';
|
import { Session } from '../session/session.ts';
|
||||||
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
|
import { checkSessionOptions, defaults } from '../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 { guardedLog } from '../log.ts';
|
||||||
import { guardedLog } from './log.ts';
|
|
||||||
|
|
||||||
/** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */
|
/** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */
|
||||||
type ReconnectTuning = { fromStart?: boolean; maxDelay?: number; minDelay?: number };
|
type ReconnectTuning = { fromStart?: boolean; maxDelay?: number; minDelay?: number };
|
||||||
@@ -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;
|
||||||
@@ -40,19 +41,30 @@ export type ClientOptions = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaults = {
|
function armConnectTimeout(
|
||||||
bindType: 'transceiver',
|
sock: Socket,
|
||||||
enquireLinkInterval: 20_000,
|
connectTimeout: number | false,
|
||||||
host: 'localhost',
|
target: { peer: string; secure: boolean },
|
||||||
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
|
settle: (result: Result<{ sock: Socket }>) => void,
|
||||||
idleTimeoutFactor: 2,
|
): NodeJS.Timeout | undefined {
|
||||||
interfaceVersion: defaultInterfaceVersion,
|
if (connectTimeout === false) return undefined;
|
||||||
password: 'pass',
|
|
||||||
port: 2775,
|
let phase = `connecting to ${target.peer}`;
|
||||||
username: 'user',
|
|
||||||
} as const;
|
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 +89,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 +161,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,
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
/** The version declared on the wire. The tables below cover 5.0, which is a superset of it. */
|
|
||||||
export const defaultInterfaceVersion = 0x34;
|
|
||||||
|
|
||||||
/** Spec rule, not a preference: a peer declaring less than 3.4 is sent no optional parameters. */
|
/** Spec rule, not a preference: a peer declaring less than 3.4 is sent no optional parameters. */
|
||||||
export const optionalParamsMinVersion = 0x34;
|
export const optionalParamsMinVersion = 0x34;
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Result } from './result.ts';
|
import type { Result } from '../result.ts';
|
||||||
import { framingRefusal } from './pdu-refusal.ts';
|
import { framingRefusal } from './refusal.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cuts a byte stream into whole PDUs.
|
* Cuts a byte stream into whole PDUs.
|
||||||
+66
-120
@@ -1,16 +1,16 @@
|
|||||||
import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
|
import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './commands.ts';
|
||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { ErrorName } from './errors.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { ParamValue } from './types.ts';
|
||||||
import type { PduHeader } from './pdu-refusal.ts';
|
import type { PduHeader } from './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 './tlvs.ts';
|
||||||
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
|
import { PduRefusedError, framingRefusal } from './refusal.ts';
|
||||||
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
|
import { cmds, commandNameById, respNameFor } from './commands.ts';
|
||||||
import { hasUdh } from './defs/constants.ts';
|
import { hasUdh } from './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 './errors.ts';
|
||||||
import { paramNumber } from './defs/types.ts';
|
import { paramNumber, valueText } from './types.ts';
|
||||||
import { tagIdOf, tlvDefault, tlvs, tlvsById, writeTlvs } from './defs/tlvs.ts';
|
import { parseTlvs, writeTlvs } from './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(
|
||||||
|
|
||||||
/** Every entry carrying body text, under whatever names their tagIds are keyed to. */
|
|
||||||
function carriedBodies(input: Record<string, TlvInput> | undefined): CarriedBody[] {
|
|
||||||
const carried: CarriedBody[] = [];
|
|
||||||
|
|
||||||
for (const [name, tlv] of Object.entries(input ?? {})) {
|
|
||||||
const tag = tagIdOf(name, tlv);
|
|
||||||
|
|
||||||
if (!tag.err && tag.tagId === tlvs.message_payload.id && typeof tlv.tagValue === 'string') {
|
|
||||||
carried.push({ name, text: tlv.tagValue, tlv });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return carried;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** messageOctets() reads short_message wherever it holds an octet, and the TLV only where it does not. */
|
|
||||||
function carriesOctets(value: ParamValue | undefined): boolean {
|
|
||||||
return Buffer.isBuffer(value) && value.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The short_message the command's own table will write, since writeParams() ignores any other. */
|
|
||||||
function writtenBody(definition: CommandDefinition, value: ParamValue | undefined): ParamValue | undefined {
|
|
||||||
return definition.params?.short_message === undefined ? undefined : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Encoded in place, settling data_coding where `settles` says no mandatory field will carry it. */
|
|
||||||
function resolveCarried(
|
|
||||||
resolved: ResolvedBody,
|
|
||||||
inputs: Record<string, TlvInput> | undefined,
|
|
||||||
dataCoding: number | undefined,
|
|
||||||
settles: boolean,
|
|
||||||
): VoidResult {
|
|
||||||
for (const carried of carriedBodies(inputs)) {
|
|
||||||
const encoded = encodeBody(carried.text, dataCoding);
|
|
||||||
|
|
||||||
if (encoded.err) return { err: new Error(`TLV "${carried.name}": ${encoded.err.message}`) };
|
|
||||||
|
|
||||||
if (settles) resolved.params.data_coding = encoded.dataCoding;
|
|
||||||
|
|
||||||
resolved.tlvs = { ...resolved.tlvs, [carried.name]: { ...carried.tlv, tagValue: encoded.buffer } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** data_coding names the alphabet of the body, and short_message settles it where it carries octets. */
|
|
||||||
function resolveBody(
|
|
||||||
params: Record<string, ParamValue | undefined>,
|
params: Record<string, ParamValue | undefined>,
|
||||||
tlvs: Record<string, TlvInput> | undefined,
|
|
||||||
definition: CommandDefinition,
|
definition: CommandDefinition,
|
||||||
): Result<ResolvedBody> {
|
): Result<{ params: Record<string, ParamValue | undefined>; source: CodingSource }> {
|
||||||
const message = writtenBody(definition, params.short_message);
|
// Only the short_message the command's own table will write, since writeParams() ignores any other.
|
||||||
const resolved: ResolvedBody = { params: { ...params }, tlvs };
|
const message = definition.params?.short_message === undefined ? undefined : params.short_message;
|
||||||
|
|
||||||
if (Buffer.isBuffer(message) && params.sm_length === undefined) {
|
if (Buffer.isBuffer(message)) {
|
||||||
resolved.params.sm_length = message.length;
|
return {
|
||||||
|
params: params.sm_length === undefined ? { ...params, sm_length: message.length } : params,
|
||||||
|
source: message.length > 0 ? 'short_message' : 'message_payload',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof message === 'string') {
|
if (typeof message !== 'string') return { params, source: 'message_payload' };
|
||||||
|
|
||||||
const encoded = encodeBody(message, codingOf(params));
|
const encoded = encodeBody(message, codingOf(params));
|
||||||
|
|
||||||
if (encoded.err) {
|
if (encoded.err) {
|
||||||
return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) };
|
return { err: new Error(`Parameter "short_message" of "${definition.command}": ${encoded.err.message}`) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (carriesOctets(encoded.buffer)) resolved.params.data_coding = encoded.dataCoding;
|
const written = { ...params, short_message: encoded.buffer, sm_length: encoded.buffer.length };
|
||||||
|
|
||||||
resolved.params.short_message = encoded.buffer;
|
if (encoded.buffer.length === 0) return { params: written, source: 'message_payload' };
|
||||||
resolved.params.sm_length = encoded.buffer.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only octets the command's own table will write can settle the alphabet the PDU declares.
|
return { params: { ...written, data_coding: encoded.dataCoding }, source: 'short_message' };
|
||||||
const settles = !carriesOctets(writtenBody(definition, resolved.params.short_message));
|
}
|
||||||
const carried = resolveCarried(
|
|
||||||
resolved,
|
|
||||||
tlvs,
|
|
||||||
settles ? codingOf(params) : codingOf(resolved.params),
|
|
||||||
settles,
|
|
||||||
);
|
|
||||||
|
|
||||||
return carried.err ? { err: carried.err } : resolved;
|
function resolveBody(
|
||||||
|
params: Record<string, ParamValue | undefined>,
|
||||||
|
tlvs: TlvInputs | undefined,
|
||||||
|
definition: CommandDefinition,
|
||||||
|
): Result<ResolvedBody> {
|
||||||
|
const shortMessage = resolveShortMessage(params, definition);
|
||||||
|
|
||||||
|
if (shortMessage.err) return { err: shortMessage.err };
|
||||||
|
|
||||||
|
const text = tlvs?.message_payload?.tagValue;
|
||||||
|
|
||||||
|
if (typeof text !== 'string') return { params: shortMessage.params, tlvs };
|
||||||
|
|
||||||
|
const encoded = encodeBody(text, codingOf(shortMessage.params));
|
||||||
|
|
||||||
|
if (encoded.err) return { err: new Error(`TLV "message_payload": ${encoded.err.message}`) };
|
||||||
|
|
||||||
|
return {
|
||||||
|
params: shortMessage.source === 'message_payload'
|
||||||
|
? { ...shortMessage.params, data_coding: encoded.dataCoding }
|
||||||
|
: shortMessage.params,
|
||||||
|
tlvs: { ...tlvs, message_payload: { tagValue: encoded.buffer } },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CommandName } from './defs/commands.ts';
|
import type { CommandName } from './commands.ts';
|
||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { ErrorName } from './errors.ts';
|
||||||
import { respNameFor } from './defs/commands.ts';
|
import { respNameFor } from './commands.ts';
|
||||||
|
|
||||||
/** A hostile peer must not be able to make us allocate arbitrarily. */
|
/** A hostile peer must not be able to make us allocate arbitrarily. */
|
||||||
export const maxPduLength = 1024 * 1024;
|
export const maxPduLength = 1024 * 1024;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ParamValue } from './types.ts';
|
||||||
|
import type { PduObject } from './pdu.ts';
|
||||||
|
import { tlvOctets } from './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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import type { ParamValue, TlvValue, WireType } from './types.ts';
|
||||||
|
import type { Result } from '../result.ts';
|
||||||
|
import { tlv } from './types.ts';
|
||||||
|
|
||||||
|
/** Only a tag read as octets or as a number may repeat, since its occurrences are listed as one of those. */
|
||||||
|
type Definition<Tag> = { id: number; multiple?: false; tag: Tag; type: WireType<Buffer | number | string> }
|
||||||
|
| { id: number; multiple: true; tag: Tag; type: WireType<Buffer> | WireType<number> };
|
||||||
|
|
||||||
|
export type TlvDefinition = Definition<string>;
|
||||||
|
|
||||||
|
/** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */
|
||||||
|
const tlvSpecs = <T extends { [K in keyof T]: Definition<K> }>(definitions: T): T => definitions;
|
||||||
|
|
||||||
|
// Ordered by tag id, mirroring the SMPP 5.0 TLV table.
|
||||||
|
const specs = tlvSpecs({
|
||||||
|
dest_addr_subunit: { id: 0x0005, tag: 'dest_addr_subunit', type: tlv.int8 },
|
||||||
|
dest_network_type: { id: 0x0006, tag: 'dest_network_type', type: tlv.int8 },
|
||||||
|
dest_bearer_type: { id: 0x0007, tag: 'dest_bearer_type', type: tlv.int8 },
|
||||||
|
dest_telematics_id: { id: 0x0008, tag: 'dest_telematics_id', type: tlv.int16 },
|
||||||
|
source_addr_subunit: { id: 0x000D, tag: 'source_addr_subunit', type: tlv.int8 },
|
||||||
|
source_network_type: { id: 0x000E, tag: 'source_network_type', type: tlv.int8 },
|
||||||
|
source_bearer_type: { id: 0x000F, tag: 'source_bearer_type', type: tlv.int8 },
|
||||||
|
source_telematics_id: { id: 0x0010, tag: 'source_telematics_id', type: tlv.int8 },
|
||||||
|
qos_time_to_live: { id: 0x0017, tag: 'qos_time_to_live', type: tlv.int32 },
|
||||||
|
payload_type: { id: 0x0019, tag: 'payload_type', type: tlv.int8 },
|
||||||
|
additional_status_info_text: { id: 0x001D, tag: 'additional_status_info_text', type: tlv.cstring },
|
||||||
|
receipted_message_id: { id: 0x001E, tag: 'receipted_message_id', type: tlv.cstring },
|
||||||
|
ms_msg_wait_facilities: { id: 0x0030, tag: 'ms_msg_wait_facilities', type: tlv.int8 },
|
||||||
|
privacy_indicator: { id: 0x0201, tag: 'privacy_indicator', type: tlv.int8 },
|
||||||
|
source_subaddress: { id: 0x0202, tag: 'source_subaddress', type: tlv.buffer },
|
||||||
|
dest_subaddress: { id: 0x0203, tag: 'dest_subaddress', type: tlv.buffer },
|
||||||
|
user_message_reference: { id: 0x0204, tag: 'user_message_reference', type: tlv.int16 },
|
||||||
|
user_response_code: { id: 0x0205, tag: 'user_response_code', type: tlv.int8 },
|
||||||
|
source_port: { id: 0x020A, tag: 'source_port', type: tlv.int16 },
|
||||||
|
dest_port: { id: 0x020B, tag: 'dest_port', type: tlv.int16 },
|
||||||
|
sar_msg_ref_num: { id: 0x020C, tag: 'sar_msg_ref_num', type: tlv.int16 },
|
||||||
|
language_indicator: { id: 0x020D, tag: 'language_indicator', type: tlv.int8 },
|
||||||
|
sar_total_segments: { id: 0x020E, tag: 'sar_total_segments', type: tlv.int8 },
|
||||||
|
sar_segment_seqnum: { id: 0x020F, tag: 'sar_segment_seqnum', type: tlv.int8 },
|
||||||
|
sc_interface_version: { id: 0x0210, tag: 'sc_interface_version', type: tlv.int8 },
|
||||||
|
callback_num_pres_ind: { id: 0x0302, multiple: true, tag: 'callback_num_pres_ind', type: tlv.int8 },
|
||||||
|
callback_num_atag: { id: 0x0303, multiple: true, tag: 'callback_num_atag', type: tlv.buffer },
|
||||||
|
number_of_messages: { id: 0x0304, tag: 'number_of_messages', type: tlv.int8 },
|
||||||
|
callback_num: { id: 0x0381, multiple: true, tag: 'callback_num', type: tlv.buffer },
|
||||||
|
dpf_result: { id: 0x0420, tag: 'dpf_result', type: tlv.int8 },
|
||||||
|
set_dpf: { id: 0x0421, tag: 'set_dpf', type: tlv.int8 },
|
||||||
|
ms_availability_status: { id: 0x0422, tag: 'ms_availability_status', type: tlv.int8 },
|
||||||
|
network_error_code: { id: 0x0423, tag: 'network_error_code', type: tlv.buffer },
|
||||||
|
message_payload: { id: 0x0424, tag: 'message_payload', type: tlv.buffer },
|
||||||
|
delivery_failure_reason: { id: 0x0425, tag: 'delivery_failure_reason', type: tlv.int8 },
|
||||||
|
more_messages_to_send: { id: 0x0426, tag: 'more_messages_to_send', type: tlv.int8 },
|
||||||
|
message_state: { id: 0x0427, tag: 'message_state', type: tlv.int8 },
|
||||||
|
congestion_state: { id: 0x0428, tag: 'congestion_state', type: tlv.int8 },
|
||||||
|
ussd_service_op: { id: 0x0501, tag: 'ussd_service_op', type: tlv.int8 },
|
||||||
|
broadcast_channel_indicator: { id: 0x0600, tag: 'broadcast_channel_indicator', type: tlv.int8 },
|
||||||
|
broadcast_content_type: { id: 0x0601, tag: 'broadcast_content_type', type: tlv.buffer },
|
||||||
|
broadcast_content_type_info: { id: 0x0602, tag: 'broadcast_content_type_info', type: tlv.string },
|
||||||
|
broadcast_message_class: { id: 0x0603, tag: 'broadcast_message_class', type: tlv.int8 },
|
||||||
|
broadcast_rep_num: { id: 0x0604, tag: 'broadcast_rep_num', type: tlv.int16 },
|
||||||
|
broadcast_frequency_interval: { id: 0x0605, tag: 'broadcast_frequency_interval', type: tlv.buffer },
|
||||||
|
broadcast_area_identifier: { id: 0x0606, multiple: true, tag: 'broadcast_area_identifier', type: tlv.buffer },
|
||||||
|
broadcast_error_status: { id: 0x0607, multiple: true, tag: 'broadcast_error_status', type: tlv.int32 },
|
||||||
|
broadcast_area_success: { id: 0x0608, tag: 'broadcast_area_success', type: tlv.int8 },
|
||||||
|
broadcast_end_time: { id: 0x0609, tag: 'broadcast_end_time', type: tlv.string },
|
||||||
|
broadcast_service_group: { id: 0x060A, tag: 'broadcast_service_group', type: tlv.string },
|
||||||
|
billing_identification: { id: 0x060B, tag: 'billing_identification', type: tlv.buffer },
|
||||||
|
source_network_id: { id: 0x060D, tag: 'source_network_id', type: tlv.cstring },
|
||||||
|
dest_network_id: { id: 0x060E, tag: 'dest_network_id', type: tlv.cstring },
|
||||||
|
source_node_id: { id: 0x060F, tag: 'source_node_id', type: tlv.string },
|
||||||
|
dest_node_id: { id: 0x0610, tag: 'dest_node_id', type: tlv.string },
|
||||||
|
dest_addr_np_resolution: { id: 0x0611, tag: 'dest_addr_np_resolution', type: tlv.int8 },
|
||||||
|
dest_addr_np_information: { id: 0x0612, tag: 'dest_addr_np_information', type: tlv.string },
|
||||||
|
dest_addr_np_country: { id: 0x0613, tag: 'dest_addr_np_country', type: tlv.int32 },
|
||||||
|
display_time: { id: 0x1201, tag: 'display_time', type: tlv.int8 },
|
||||||
|
sms_signal: { id: 0x1203, tag: 'sms_signal', type: tlv.int16 },
|
||||||
|
ms_validity: { id: 0x1204, tag: 'ms_validity', type: tlv.buffer },
|
||||||
|
alert_on_message_delivery: { id: 0x130C, tag: 'alert_on_message_delivery', type: tlv.int8 },
|
||||||
|
its_reply_type: { id: 0x1380, tag: 'its_reply_type', type: tlv.int8 },
|
||||||
|
its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer },
|
||||||
|
});
|
||||||
|
|
||||||
|
type Specs = typeof specs;
|
||||||
|
|
||||||
|
export type TlvName = keyof Specs;
|
||||||
|
|
||||||
|
// SMPP 5.0's other spellings, which only name the tag to key instead.
|
||||||
|
const alternates: Record<string, TlvName> = {
|
||||||
|
alert_on_msg_delivery: 'alert_on_message_delivery',
|
||||||
|
failed_broadcast_area_identifier: 'broadcast_area_identifier',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tlvs: Record<TlvName, TlvDefinition> = specs;
|
||||||
|
|
||||||
|
export const tlvsById: Record<number, TlvDefinition> = {};
|
||||||
|
|
||||||
|
for (const definition of Object.values<TlvDefinition>(specs)) {
|
||||||
|
tlvsById[definition.id] = definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback for tags this table does not know: keep the raw octets. */
|
||||||
|
export const tlvDefault: WireType<Buffer> = tlv.buffer;
|
||||||
|
|
||||||
|
type Repeated<K extends TlvName, V> = Specs[K] extends { multiple: true } ? V[] : V;
|
||||||
|
|
||||||
|
type WireValue<K extends TlvName> = Specs[K]['type']['default'];
|
||||||
|
|
||||||
|
type ReadValue<K extends TlvName> = Repeated<K, WireValue<K>>;
|
||||||
|
|
||||||
|
/** A lone text field also takes a number, and an octet field text, which goes out as latin1. */
|
||||||
|
type WriteValue<K extends TlvName> = Specs[K] extends { multiple: true } ? ReadValue<K>
|
||||||
|
: WireValue<K> extends number ? number
|
||||||
|
: WireValue<K> extends string ? number | string
|
||||||
|
: Buffer | string;
|
||||||
|
|
||||||
|
type KnownTlv<K extends TlvName> = { tagId: number; tagName: K; tagValue: ReadValue<K> };
|
||||||
|
|
||||||
|
type UnknownTlv = { tagId: number; tagName: undefined; tagValue: Buffer };
|
||||||
|
|
||||||
|
/** Keyed by tag name, or by its decimal id where the table defines no name. */
|
||||||
|
export type Tlvs = { [K in TlvName]?: KnownTlv<K> } & Partial<Record<`${number}`, UnknownTlv>>;
|
||||||
|
|
||||||
|
export type Tlv = { [K in TlvName]: KnownTlv<K> }[TlvName] | UnknownTlv;
|
||||||
|
|
||||||
|
/** Keyed like `Tlvs`. */
|
||||||
|
export type TlvInputs = { [K in TlvName]?: { tagValue: WriteValue<K> } }
|
||||||
|
& Partial<Record<`${number}`, { tagValue: Buffer | string }>>;
|
||||||
|
|
||||||
|
function isTlvInput(input: unknown): input is { tagValue: TlvValue } {
|
||||||
|
if (typeof input !== 'object' || input === null || !('tagValue' in input)) return false;
|
||||||
|
|
||||||
|
const value = input.tagValue;
|
||||||
|
|
||||||
|
if (!Array.isArray(value)) return Buffer.isBuffer(value) || typeof value === 'number' || typeof value === 'string';
|
||||||
|
|
||||||
|
return value.every(one => Buffer.isBuffer(one)) || value.every(one => typeof one === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyedTagId(name: string): Result<{ tagId: number }> {
|
||||||
|
if (isTlvName(name)) return { tagId: specs[name].id };
|
||||||
|
|
||||||
|
const alternate = Object.hasOwn(alternates, name) ? alternates[name] : undefined;
|
||||||
|
|
||||||
|
if (alternate) return { err: new Error(`TLV "${name}": key it ${alternate}, the name it reads back under`) };
|
||||||
|
|
||||||
|
if (!/^(0|[1-9]\d*)$/.test(name)) {
|
||||||
|
return { err: new Error(`TLV "${name}": unknown tag name; key a tag the table does not define by its decimal id`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagId = Number(name);
|
||||||
|
|
||||||
|
if (tagId > 0xFFFF) return { err: new Error(`TLV "${name}": tag id out of range 0-65535`) };
|
||||||
|
|
||||||
|
const known = tlvsById[tagId];
|
||||||
|
|
||||||
|
return known ? { err: new Error(`TLV "${name}": the table names this tag ${known.tag}, key it by that`) } : { tagId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryOf(name: string, input: unknown): Result<{ tagId: number; tagValue: TlvValue }> {
|
||||||
|
if (!isTlvInput(input)) {
|
||||||
|
return { err: new Error(`TLV "${name}": give it as { tagValue }, holding a Buffer, a number, a string, or an array of Buffers or of numbers`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyed = keyedTagId(name);
|
||||||
|
|
||||||
|
if (keyed.err) return { err: keyed.err };
|
||||||
|
|
||||||
|
if ('tagId' in input && input.tagId !== undefined && input.tagId !== keyed.tagId) {
|
||||||
|
return { err: new Error(`TLV "${name}": its tagId does not match ${String(keyed.tagId)}, the tag its key names; drop the tagId`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tagId: keyed.tagId, tagValue: input.tagValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each TLV as its four octet header and the value the tag's own wire type writes. */
|
||||||
|
export function writeTlvs(inputs: TlvInputs | undefined): Result<{ chunks: Buffer[] }> {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
for (const [name, input] of Object.entries<unknown>(inputs ?? {})) {
|
||||||
|
const tag = entryOf(name, input);
|
||||||
|
|
||||||
|
if (tag.err) return { err: tag.err };
|
||||||
|
|
||||||
|
const definition = tlvsById[tag.tagId];
|
||||||
|
const values = occurrences(tag.tagValue, definition?.multiple === true);
|
||||||
|
|
||||||
|
if (values.err) return { err: new Error(`TLV "${name}": ${values.err.message}`) };
|
||||||
|
|
||||||
|
for (const value of values.values) {
|
||||||
|
const chunk = writeTlv(tag.tagId, definition?.type ?? tlvDefault, value);
|
||||||
|
|
||||||
|
if (chunk.err) return { err: new Error(`TLV "${name}": ${chunk.err.message}`) };
|
||||||
|
|
||||||
|
chunks.push(chunk.chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
},
|
},
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { cmds, cmdsById } from './commands.ts';
|
|
||||||
import { consts, constsById } from './constants.ts';
|
|
||||||
import { encodings } from './encodings.ts';
|
|
||||||
import { errors, errorsById } from './errors.ts';
|
|
||||||
import { tlvs, tlvsById } from './tlvs.ts';
|
|
||||||
import { types } from './types.ts';
|
|
||||||
|
|
||||||
export const defs = {
|
|
||||||
cmds,
|
|
||||||
cmdsById,
|
|
||||||
consts,
|
|
||||||
constsById,
|
|
||||||
encodings,
|
|
||||||
errors,
|
|
||||||
errorsById,
|
|
||||||
tlvs,
|
|
||||||
tlvsById,
|
|
||||||
types,
|
|
||||||
};
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import type { ParamValue, WireType } from './types.ts';
|
|
||||||
import type { Result } from '../result.ts';
|
|
||||||
import { tlv } from './types.ts';
|
|
||||||
|
|
||||||
export type TlvDefinition = {
|
|
||||||
id: number;
|
|
||||||
multiple?: boolean;
|
|
||||||
tag: string;
|
|
||||||
type: WireType;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */
|
|
||||||
const tlvSpecs = <T extends { [K in keyof T]: { id: number; multiple?: boolean; tag: K; type: WireType } }>(
|
|
||||||
definitions: T,
|
|
||||||
): T => definitions;
|
|
||||||
|
|
||||||
// Ordered by tag id, mirroring the SMPP 5.0 TLV table.
|
|
||||||
const specs = tlvSpecs({
|
|
||||||
dest_addr_subunit: { id: 0x0005, tag: 'dest_addr_subunit', type: tlv.int8 },
|
|
||||||
dest_network_type: { id: 0x0006, tag: 'dest_network_type', type: tlv.int8 },
|
|
||||||
dest_bearer_type: { id: 0x0007, tag: 'dest_bearer_type', type: tlv.int8 },
|
|
||||||
dest_telematics_id: { id: 0x0008, tag: 'dest_telematics_id', type: tlv.int16 },
|
|
||||||
source_addr_subunit: { id: 0x000D, tag: 'source_addr_subunit', type: tlv.int8 },
|
|
||||||
source_network_type: { id: 0x000E, tag: 'source_network_type', type: tlv.int8 },
|
|
||||||
source_bearer_type: { id: 0x000F, tag: 'source_bearer_type', type: tlv.int8 },
|
|
||||||
source_telematics_id: { id: 0x0010, tag: 'source_telematics_id', type: tlv.int8 },
|
|
||||||
qos_time_to_live: { id: 0x0017, tag: 'qos_time_to_live', type: tlv.int32 },
|
|
||||||
payload_type: { id: 0x0019, tag: 'payload_type', type: tlv.int8 },
|
|
||||||
additional_status_info_text: { id: 0x001D, tag: 'additional_status_info_text', type: tlv.cstring },
|
|
||||||
receipted_message_id: { id: 0x001E, tag: 'receipted_message_id', type: tlv.cstring },
|
|
||||||
ms_msg_wait_facilities: { id: 0x0030, tag: 'ms_msg_wait_facilities', type: tlv.int8 },
|
|
||||||
privacy_indicator: { id: 0x0201, tag: 'privacy_indicator', type: tlv.int8 },
|
|
||||||
source_subaddress: { id: 0x0202, tag: 'source_subaddress', type: tlv.buffer },
|
|
||||||
dest_subaddress: { id: 0x0203, tag: 'dest_subaddress', type: tlv.buffer },
|
|
||||||
user_message_reference: { id: 0x0204, tag: 'user_message_reference', type: tlv.int16 },
|
|
||||||
user_response_code: { id: 0x0205, tag: 'user_response_code', type: tlv.int8 },
|
|
||||||
source_port: { id: 0x020A, tag: 'source_port', type: tlv.int16 },
|
|
||||||
dest_port: { id: 0x020B, tag: 'dest_port', type: tlv.int16 },
|
|
||||||
sar_msg_ref_num: { id: 0x020C, tag: 'sar_msg_ref_num', type: tlv.int16 },
|
|
||||||
language_indicator: { id: 0x020D, tag: 'language_indicator', type: tlv.int8 },
|
|
||||||
sar_total_segments: { id: 0x020E, tag: 'sar_total_segments', type: tlv.int8 },
|
|
||||||
sar_segment_seqnum: { id: 0x020F, tag: 'sar_segment_seqnum', type: tlv.int8 },
|
|
||||||
sc_interface_version: { id: 0x0210, tag: 'sc_interface_version', type: tlv.int8 },
|
|
||||||
callback_num_pres_ind: { id: 0x0302, multiple: true, tag: 'callback_num_pres_ind', type: tlv.int8 },
|
|
||||||
callback_num_atag: { id: 0x0303, multiple: true, tag: 'callback_num_atag', type: tlv.buffer },
|
|
||||||
number_of_messages: { id: 0x0304, tag: 'number_of_messages', type: tlv.int8 },
|
|
||||||
callback_num: { id: 0x0381, multiple: true, tag: 'callback_num', type: tlv.buffer },
|
|
||||||
dpf_result: { id: 0x0420, tag: 'dpf_result', type: tlv.int8 },
|
|
||||||
set_dpf: { id: 0x0421, tag: 'set_dpf', type: tlv.int8 },
|
|
||||||
ms_availability_status: { id: 0x0422, tag: 'ms_availability_status', type: tlv.int8 },
|
|
||||||
network_error_code: { id: 0x0423, tag: 'network_error_code', type: tlv.buffer },
|
|
||||||
message_payload: { id: 0x0424, tag: 'message_payload', type: tlv.buffer },
|
|
||||||
delivery_failure_reason: { id: 0x0425, tag: 'delivery_failure_reason', type: tlv.int8 },
|
|
||||||
more_messages_to_send: { id: 0x0426, tag: 'more_messages_to_send', type: tlv.int8 },
|
|
||||||
message_state: { id: 0x0427, tag: 'message_state', type: tlv.int8 },
|
|
||||||
congestion_state: { id: 0x0428, tag: 'congestion_state', type: tlv.int8 },
|
|
||||||
ussd_service_op: { id: 0x0501, tag: 'ussd_service_op', type: tlv.int8 },
|
|
||||||
broadcast_channel_indicator: { id: 0x0600, tag: 'broadcast_channel_indicator', type: tlv.int8 },
|
|
||||||
broadcast_content_type: { id: 0x0601, tag: 'broadcast_content_type', type: tlv.buffer },
|
|
||||||
broadcast_content_type_info: { id: 0x0602, tag: 'broadcast_content_type_info', type: tlv.string },
|
|
||||||
broadcast_message_class: { id: 0x0603, tag: 'broadcast_message_class', type: tlv.int8 },
|
|
||||||
broadcast_rep_num: { id: 0x0604, tag: 'broadcast_rep_num', type: tlv.int16 },
|
|
||||||
broadcast_frequency_interval: { id: 0x0605, tag: 'broadcast_frequency_interval', type: tlv.buffer },
|
|
||||||
broadcast_area_identifier: { id: 0x0606, multiple: true, tag: 'broadcast_area_identifier', type: tlv.buffer },
|
|
||||||
broadcast_error_status: { id: 0x0607, multiple: true, tag: 'broadcast_error_status', type: tlv.int32 },
|
|
||||||
broadcast_area_success: { id: 0x0608, tag: 'broadcast_area_success', type: tlv.int8 },
|
|
||||||
broadcast_end_time: { id: 0x0609, tag: 'broadcast_end_time', type: tlv.string },
|
|
||||||
broadcast_service_group: { id: 0x060A, tag: 'broadcast_service_group', type: tlv.string },
|
|
||||||
billing_identification: { id: 0x060B, tag: 'billing_identification', type: tlv.buffer },
|
|
||||||
source_network_id: { id: 0x060D, tag: 'source_network_id', type: tlv.cstring },
|
|
||||||
dest_network_id: { id: 0x060E, tag: 'dest_network_id', type: tlv.cstring },
|
|
||||||
source_node_id: { id: 0x060F, tag: 'source_node_id', type: tlv.string },
|
|
||||||
dest_node_id: { id: 0x0610, tag: 'dest_node_id', type: tlv.string },
|
|
||||||
dest_addr_np_resolution: { id: 0x0611, tag: 'dest_addr_np_resolution', type: tlv.int8 },
|
|
||||||
dest_addr_np_information: { id: 0x0612, tag: 'dest_addr_np_information', type: tlv.string },
|
|
||||||
dest_addr_np_country: { id: 0x0613, tag: 'dest_addr_np_country', type: tlv.int32 },
|
|
||||||
display_time: { id: 0x1201, tag: 'display_time', type: tlv.int8 },
|
|
||||||
sms_signal: { id: 0x1203, tag: 'sms_signal', type: tlv.int16 },
|
|
||||||
ms_validity: { id: 0x1204, tag: 'ms_validity', type: tlv.buffer },
|
|
||||||
alert_on_message_delivery: { id: 0x130C, tag: 'alert_on_message_delivery', type: tlv.int8 },
|
|
||||||
its_reply_type: { id: 0x1380, tag: 'its_reply_type', type: tlv.int8 },
|
|
||||||
its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer },
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TlvName = keyof typeof specs;
|
|
||||||
|
|
||||||
export const tlvs: Record<TlvName, TlvDefinition> & Record<string, TlvDefinition> = {
|
|
||||||
...specs,
|
|
||||||
// Alternate spellings; the definition behind each keeps its canonical name.
|
|
||||||
alert_on_msg_delivery: specs.alert_on_message_delivery,
|
|
||||||
failed_broadcast_area_identifier: specs.broadcast_area_identifier,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tlvsById: Record<number, TlvDefinition> = {};
|
|
||||||
|
|
||||||
for (const definition of Object.values<TlvDefinition>(specs)) {
|
|
||||||
tlvsById[definition.id] = definition;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fallback for tags this table does not know: keep the raw octets. */
|
|
||||||
export const tlvDefault: WireType = tlv.buffer;
|
|
||||||
|
|
||||||
export type Tlv = {
|
|
||||||
tagId: number;
|
|
||||||
tagName: string | undefined;
|
|
||||||
tagValue: ParamValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TlvInput = {
|
|
||||||
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
|
|
||||||
tagId?: number | undefined;
|
|
||||||
tagValue: ParamValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
|
||||||
const tagId = input.tagId ?? tlvs[name]?.id;
|
|
||||||
|
|
||||||
if (tagId === undefined) {
|
|
||||||
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
|
|
||||||
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { tagId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Each TLV as its four octet header and the value the tag's own wire type writes. */
|
|
||||||
export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
|
|
||||||
for (const [name, input] of Object.entries(inputs ?? {})) {
|
|
||||||
const tag = tagIdOf(name, input);
|
|
||||||
|
|
||||||
if (tag.err) return { err: tag.err };
|
|
||||||
|
|
||||||
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
|
|
||||||
const sized = type.size(input.tagValue);
|
|
||||||
|
|
||||||
if (sized.err) {
|
|
||||||
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sized.size > 0xffff) {
|
|
||||||
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunk = Buffer.alloc(sized.size + 4);
|
|
||||||
|
|
||||||
chunk.writeUInt16BE(tag.tagId, 0);
|
|
||||||
chunk.writeUInt16BE(sized.size, 2);
|
|
||||||
|
|
||||||
const written = type.write(input.tagValue, chunk, 4);
|
|
||||||
|
|
||||||
if (written.err) {
|
|
||||||
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks.push(chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { chunks };
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/** Whatever was thrown or rejected, as an Error. `String()` throws on some values; this cannot. */
|
|
||||||
export function errorFrom(reason: unknown): Error {
|
|
||||||
if (reason instanceof Error) return reason;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new Error(String(reason));
|
|
||||||
} catch {
|
|
||||||
return new Error('A thrown value that cannot be converted to a string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** String() throws on a null-prototype object or a symbol, so only a string or number is printed. */
|
|
||||||
export function namedValue(value: unknown): string {
|
|
||||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : typeof value;
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import type { PduObject } from './pdu.ts';
|
|
||||||
import type { SmppLog } from './log.ts';
|
|
||||||
import { ExpiringGroups } from './expiring-groups.ts';
|
|
||||||
import { IdleWaiters } from './idle-waiters.ts';
|
|
||||||
|
|
||||||
export type HeldMessagesOptions = {
|
|
||||||
log: SmppLog;
|
|
||||||
max: number;
|
|
||||||
/** Injected so expiry can be exercised without a wall clock. */
|
|
||||||
now?: (() => number) | undefined;
|
|
||||||
timeout: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The peer's own sequence number, which is what our answer to this message will carry. */
|
|
||||||
function keyOf(pduObjs: PduObject[]): string | undefined {
|
|
||||||
const first = pduObjs[0];
|
|
||||||
|
|
||||||
return first ? String(first.seqNr) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The messages handed to the application that it has not answered yet, held by their segments. */
|
|
||||||
export class HeldMessages {
|
|
||||||
private readonly held: ExpiringGroups<PduObject[]>;
|
|
||||||
private readonly idleWaiters = new IdleWaiters();
|
|
||||||
private readonly log: SmppLog;
|
|
||||||
private readonly max: number;
|
|
||||||
|
|
||||||
constructor(options: HeldMessagesOptions) {
|
|
||||||
this.held = new ExpiringGroups({
|
|
||||||
max: options.max,
|
|
||||||
now: options.now,
|
|
||||||
onSweep: () => { this.sweep(); },
|
|
||||||
timeout: options.timeout,
|
|
||||||
});
|
|
||||||
this.log = options.log;
|
|
||||||
this.max = options.max;
|
|
||||||
}
|
|
||||||
|
|
||||||
get size(): number {
|
|
||||||
return this.held.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An application that answers no message at all may not grow this without end. */
|
|
||||||
hold(pduObjs: PduObject[]): void {
|
|
||||||
const key = keyOf(pduObjs);
|
|
||||||
|
|
||||||
if (key === undefined) return;
|
|
||||||
|
|
||||||
this.sweep();
|
|
||||||
|
|
||||||
if (this.held.get(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a drain is still waiting for this message to be answered. */
|
|
||||||
has(pduObjs: PduObject[]): boolean {
|
|
||||||
const key = keyOf(pduObjs);
|
|
||||||
|
|
||||||
return key !== undefined && this.held.get(key) === pduObjs;
|
|
||||||
}
|
|
||||||
|
|
||||||
release(pduObjs: PduObject[]): void {
|
|
||||||
const key = keyOf(pduObjs);
|
|
||||||
|
|
||||||
// Identity, not the key: a wrapped sequence number must not release someone else's message.
|
|
||||||
if (key === undefined || this.held.get(key) !== pduObjs) return;
|
|
||||||
|
|
||||||
this.held.delete(key);
|
|
||||||
this.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */
|
|
||||||
clear(): void {
|
|
||||||
this.held.takeAll();
|
|
||||||
this.idleWaiters.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves 0 once every message has been answered, or with how many have not. */
|
|
||||||
idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
|
|
||||||
return this.idleWaiters.wait(() => this.held.size, timeout, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
private dropOldest(): void {
|
|
||||||
const oldest = this.held.takeOldest();
|
|
||||||
|
|
||||||
if (!oldest) return;
|
|
||||||
|
|
||||||
const [seqNr] = oldest;
|
|
||||||
|
|
||||||
this.log.warn('heldMessages - buffer full, dropping the oldest message', {
|
|
||||||
max: this.max,
|
|
||||||
seqNr: Number(seqNr),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drops every message past its deadline. Runs before each hold and on its own timer. */
|
|
||||||
sweep(): void {
|
|
||||||
const expired = this.held.takeExpired();
|
|
||||||
|
|
||||||
if (expired.length === 0) return;
|
|
||||||
|
|
||||||
this.log.warn('heldMessages - messages the application never answered', {
|
|
||||||
messages: expired.length,
|
|
||||||
});
|
|
||||||
this.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
private settle(): void {
|
|
||||||
if (this.held.size === 0) this.idleWaiters.settle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+52
-34
@@ -1,13 +1,20 @@
|
|||||||
export { client } from './client.ts';
|
import { cmds, cmdsById } from './codec/commands.ts';
|
||||||
export { server, SmppServer } from './server.ts';
|
import { consts, constsById } from './codec/constants.ts';
|
||||||
export { Session } from './session.ts';
|
import { encodings } from './codec/encodings.ts';
|
||||||
|
import { errors, errorsById } from './codec/errors.ts';
|
||||||
|
import { tlvs, tlvsById } from './codec/tlvs.ts';
|
||||||
|
import { types } from './codec/types.ts';
|
||||||
|
|
||||||
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
|
export { client } from './client/client.ts';
|
||||||
export { consts, constsById } from './defs/constants.ts';
|
export { server, SmppServer } from './server/server.ts';
|
||||||
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './defs/encodings.ts';
|
export { Session } from './session/session.ts';
|
||||||
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
|
|
||||||
export { tlvs, tlvsById } from './defs/tlvs.ts';
|
export { cmds, cmdsById, commandNameById, isCommandName } from './codec/commands.ts';
|
||||||
export { types } from './defs/types.ts';
|
export { consts, constsById } from './codec/constants.ts';
|
||||||
|
export { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, messageClassOf, unencodable } from './codec/encodings.ts';
|
||||||
|
export { errorNameById, errors, errorsById, isErrorName } from './codec/errors.ts';
|
||||||
|
export { isTlvName, tlvs, tlvsById } from './codec/tlvs.ts';
|
||||||
|
export { types } from './codec/types.ts';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
isCommand,
|
isCommand,
|
||||||
@@ -16,9 +23,9 @@ export {
|
|||||||
objToPdu,
|
objToPdu,
|
||||||
pduReturn,
|
pduReturn,
|
||||||
pduToObj,
|
pduToObj,
|
||||||
} from './pdu.ts';
|
} from './codec/pdu.ts';
|
||||||
|
|
||||||
export { maxPduLength, PduRefusedError } from './pdu-refusal.ts';
|
export { maxPduLength, PduRefusedError } from './codec/refusal.ts';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
bitCount,
|
bitCount,
|
||||||
@@ -29,27 +36,27 @@ export {
|
|||||||
splitMessage,
|
splitMessage,
|
||||||
} from './message.ts';
|
} from './message.ts';
|
||||||
|
|
||||||
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
|
export { dlrFromPdu, parseReceipt, receiptCodes } from './protocol/dlr.ts';
|
||||||
export { messageOctets } from './message-body.ts';
|
export { messageOctets } from './protocol/message-body.ts';
|
||||||
export { concatOf } from './concat.ts';
|
export { concatOf } from './protocol/concat.ts';
|
||||||
export { concatInfo } from './udh.ts';
|
export { concatInfo } from './protocol/udh.ts';
|
||||||
export { PduFramer } from './pdu-framer.ts';
|
export { PduFramer } from './codec/pdu-framer.ts';
|
||||||
export { uuidv7 } from './uuid.ts';
|
export { uuidv7 } from './protocol/uuid.ts';
|
||||||
|
|
||||||
export type { BindType, ClientOptions } from './client.ts';
|
export type { BindType, ClientOptions } from './client/client.ts';
|
||||||
export type { Dlr, Receipt } from './dlr.ts';
|
export type { Dlr, Receipt } from './protocol/dlr.ts';
|
||||||
export type { SendDlrResult, SendRespOptions, Sms, SmsInput } from './sms.ts';
|
export type { SendDlrResult, SendRespOptions, Sms } from './session/sms.ts';
|
||||||
export type { Concat } from './concat.ts';
|
export type { Concat } from './protocol/concat.ts';
|
||||||
export type { ConcatInfo } from './udh.ts';
|
export type { ConcatInfo } from './protocol/udh.ts';
|
||||||
export type { Result, VoidResult } from './result.ts';
|
export type { Result, VoidResult } from './result.ts';
|
||||||
export type { SmppLog } from './log.ts';
|
export type { SmppLog } from './log.ts';
|
||||||
export type { SmsIdFormat, SmsIdNotation } from './sms-id.ts';
|
export type { SmsIdFormat, SmsIdNotation } from './protocol/message-ids.ts';
|
||||||
export type {
|
export type {
|
||||||
AuthenticateInput,
|
AuthenticateInput,
|
||||||
AuthenticateResult,
|
AuthenticateResult,
|
||||||
ServerEvents,
|
ServerEvents,
|
||||||
ServerOptions,
|
ServerOptions,
|
||||||
} from './server.ts';
|
} from './server/server.ts';
|
||||||
export type {
|
export type {
|
||||||
CloseOptions,
|
CloseOptions,
|
||||||
MessageDlr,
|
MessageDlr,
|
||||||
@@ -59,16 +66,27 @@ export type {
|
|||||||
SendSmsResult,
|
SendSmsResult,
|
||||||
SessionEvents,
|
SessionEvents,
|
||||||
SessionOptions,
|
SessionOptions,
|
||||||
} from './session.ts';
|
} from './session/session.ts';
|
||||||
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
|
export type { CommandName, PduParams, PduParamsInput } from './codec/commands.ts';
|
||||||
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
|
export type { ConstGroup, MessageState, SubmitMessagingMode } from './codec/constants.ts';
|
||||||
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
|
export type { Encoding, EncodingName, Unencodable } from './codec/encodings.ts';
|
||||||
export type { ErrorName } from './defs/errors.ts';
|
export type { ErrorName } from './codec/errors.ts';
|
||||||
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
export type { PduObject, PduObjectInput, TlvInputs } from './codec/pdu.ts';
|
||||||
export type { PduHeader } from './pdu-refusal.ts';
|
export type { PduHeader } from './codec/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 './codec/tlvs.ts';
|
||||||
export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.ts';
|
export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './codec/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 const defs = {
|
||||||
|
cmds,
|
||||||
|
cmdsById,
|
||||||
|
consts,
|
||||||
|
constsById,
|
||||||
|
encodings,
|
||||||
|
errors,
|
||||||
|
errorsById,
|
||||||
|
tlvs,
|
||||||
|
tlvsById,
|
||||||
|
types,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import type { SmppLog } from './log.ts';
|
|
||||||
import type { VoidResult } from './result.ts';
|
|
||||||
|
|
||||||
export type LinkGateOptions = {
|
|
||||||
log: SmppLog;
|
|
||||||
now?: (() => number) | undefined;
|
|
||||||
/** How long a request may wait for a link. 0 waits for as long as one may still arrive. */
|
|
||||||
timeout: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Waiter = (result: VoidResult) => void;
|
|
||||||
|
|
||||||
function aborted(): Error {
|
|
||||||
return new Error('Aborted while waiting for a link');
|
|
||||||
}
|
|
||||||
|
|
||||||
function expired(): Error {
|
|
||||||
return new Error('The link did not come back in time');
|
|
||||||
}
|
|
||||||
|
|
||||||
function over(): Error {
|
|
||||||
return new Error('Session is closed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where a request with no link to go out on waits for the next one. */
|
|
||||||
export class LinkGate {
|
|
||||||
private readonly log: SmppLog;
|
|
||||||
private readonly now: () => number;
|
|
||||||
private readonly timeout: number;
|
|
||||||
private readonly waiting = new Set<Waiter>();
|
|
||||||
private returning = false;
|
|
||||||
private up = true;
|
|
||||||
|
|
||||||
constructor(options: LinkGateOptions) {
|
|
||||||
this.log = options.log;
|
|
||||||
this.now = options.now ?? Date.now;
|
|
||||||
this.timeout = options.timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a request can go out right now. A link that is attached but not yet bound cannot. */
|
|
||||||
isUp(): boolean {
|
|
||||||
return this.up;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Why the gate will never admit a request, or undefined while one may still get through. */
|
|
||||||
refusal(): Error | undefined {
|
|
||||||
return this.up || this.returning ? undefined : over();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One budget for a request, however many links it waits through. 0 never gives up. */
|
|
||||||
hold(signal: AbortSignal | undefined): () => Promise<VoidResult> {
|
|
||||||
const deadline = this.timeout > 0 ? this.now() + this.timeout : 0;
|
|
||||||
|
|
||||||
return () => this.wait(deadline, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A link is up and bound: everything held goes out on it. */
|
|
||||||
open(): void {
|
|
||||||
this.up = true;
|
|
||||||
this.returning = false;
|
|
||||||
|
|
||||||
if (this.waiting.size > 0) {
|
|
||||||
this.log.verbose('linkGate - sending what was held for a link', { held: this.waiting.size });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.release({});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The link is gone. `returning` says whether another one is on its way. */
|
|
||||||
shut(returning: boolean): void {
|
|
||||||
this.up = false;
|
|
||||||
this.returning = returning;
|
|
||||||
|
|
||||||
if (!returning) this.release({ err: over() });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves once a link can carry the request, or with the reason none ever will. */
|
|
||||||
private wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
|
||||||
if (this.up) return Promise.resolve({});
|
|
||||||
|
|
||||||
const refused = this.refusal();
|
|
||||||
|
|
||||||
if (refused) return Promise.resolve({ err: refused });
|
|
||||||
|
|
||||||
if (signal?.aborted === true) return Promise.resolve({ err: aborted() });
|
|
||||||
|
|
||||||
const left = deadline === 0 ? 0 : deadline - this.now();
|
|
||||||
|
|
||||||
if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() });
|
|
||||||
|
|
||||||
return this.waitForLink(left, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
private waitForLink(left: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
|
||||||
this.log.verbose('linkGate - holding a request until a link is back', { timeout: left });
|
|
||||||
|
|
||||||
return new Promise<VoidResult>(resolve => {
|
|
||||||
let timer: NodeJS.Timeout | undefined = undefined;
|
|
||||||
const settle = (result: VoidResult): void => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
|
|
||||||
signal?.removeEventListener('abort', onAbort);
|
|
||||||
this.waiting.delete(settle);
|
|
||||||
resolve(result);
|
|
||||||
};
|
|
||||||
const giveUp = (): void => {
|
|
||||||
this.log.warn('linkGate - no link came back in time', { timeout: left });
|
|
||||||
settle({ err: expired() });
|
|
||||||
};
|
|
||||||
|
|
||||||
function onAbort(): void {
|
|
||||||
settle({ err: aborted() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled.
|
|
||||||
if (left > 0) timer = setTimeout(giveUp, left);
|
|
||||||
|
|
||||||
signal?.addEventListener('abort', onAbort, { once: true });
|
|
||||||
this.waiting.add(settle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private release(result: VoidResult): void {
|
|
||||||
for (const settle of [...this.waiting]) {
|
|
||||||
settle(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
import type { Result } from './result.ts';
|
import type { Result } from './result.ts';
|
||||||
import type { EncodingName } from './defs/encodings.ts';
|
import type { EncodingName } from './codec/encodings.ts';
|
||||||
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts';
|
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './codec/encodings.ts';
|
||||||
import { hasUdh } from './defs/constants.ts';
|
import { hasUdh } from './codec/constants.ts';
|
||||||
import { udhLength } from './udh.ts';
|
import { udhLength } from './protocol/udh.ts';
|
||||||
|
|
||||||
/** A single SMS carries 1120 bits, whatever the alphabet. */
|
/** A single SMS carries 1120 bits, whatever the alphabet. */
|
||||||
const singleMessageBits = 1120;
|
const singleMessageBits = 1120;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Dlr } from './dlr.ts';
|
import type { Dlr } from '../protocol/dlr.ts';
|
||||||
import type { MessageState } from './defs/constants.ts';
|
import type { MessageState } from '../codec/constants.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 { parseSegmentId } from './sms-id.ts';
|
import { parseSegmentId } from '../protocol/message-ids.ts';
|
||||||
|
|
||||||
export type MessageDlr = Dlr & { segments: Dlr[]; smsId: string };
|
export type MessageDlr = Dlr & { segments: Dlr[]; smsId: string };
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Concat } from './concat.ts';
|
import type { Concat } from '../protocol/concat.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { PduObject } from '../codec/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 { messageOctets } from './message-body.ts';
|
import { defaults } from '../options.ts';
|
||||||
import { paramNumber, paramText } from './defs/types.ts';
|
import { detach, retainedOctets } from '../codec/retained-pdu.ts';
|
||||||
import { uuidv7 } from './uuid.ts';
|
import { messageOctets } from '../protocol/message-body.ts';
|
||||||
|
import { paramNumber, paramText } from '../codec/types.ts';
|
||||||
|
import { uuidv7 } from '../protocol/uuid.ts';
|
||||||
|
|
||||||
/** A concatenated message given up on, whose segments the peer has already been answered for. */
|
/** A concatenated message given up on, whose segments the peer has already been answered for. */
|
||||||
export type LostGroup = {
|
export type LostGroup = {
|
||||||
@@ -43,59 +43,12 @@ export type Collected =
|
|||||||
whole?: PduObject[] | undefined;
|
whole?: PduObject[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 +86,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 ?? defaults.maxOctets;
|
||||||
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 +116,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 +139,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 +176,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 +219,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);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { EncodingName, Unencodable } from './defs/encodings.ts';
|
import type { EncodingName, Unencodable } from '../codec/encodings.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { ParamValue } from '../codec/types.ts';
|
||||||
import type { SubmitMessagingMode } from './defs/constants.ts';
|
import type { SubmitMessagingMode } from '../codec/constants.ts';
|
||||||
import type { PduObject, PduObjectInput } from './pdu.ts';
|
import type { PduObject, PduObjectInput } from '../codec/pdu.ts';
|
||||||
import type { Result } from './result.ts';
|
import type { Result } from '../result.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
import type { SmsIdNotation } from './sms-id.ts';
|
import type { SmsIdNotation } from '../protocol/message-ids.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 '../codec/constants.ts';
|
||||||
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts';
|
import { cstring, paramText } from '../codec/types.ts';
|
||||||
import { namedValue } from './error-from.ts';
|
import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from '../codec/encodings.ts';
|
||||||
import { normaliseSmsId } from './sms-id.ts';
|
import { namedValue } from '../result.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { normaliseSmsId } from '../protocol/message-ids.ts';
|
||||||
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
import { maxSegments, smppTime, splitMessage } from '../message.ts';
|
||||||
|
|
||||||
export type SendSmsOptions = {
|
export type SendSmsOptions = {
|
||||||
dlr?: boolean;
|
dlr?: boolean;
|
||||||
@@ -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, {
|
||||||
@@ -1,76 +1,11 @@
|
|||||||
import type { Dlr } from './dlr.ts';
|
import type { PduObject } from './codec/pdu.ts';
|
||||||
import type { MessageDlr } from './dlr-merger.ts';
|
|
||||||
import type { PduObject } from './pdu.ts';
|
|
||||||
import type { PduRefusedError } from './pdu-refusal.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/session.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
import type { SmsIdFormat } from './sms-id.ts';
|
import type { SmsIdFormat } from './protocol/message-ids.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 { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './protocol/message-ids.ts';
|
||||||
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
import { namedValue, quoted } from './result.ts';
|
||||||
import { namedValue } from './error-from.ts';
|
|
||||||
|
|
||||||
export type SessionEvents = {
|
|
||||||
close: [];
|
|
||||||
data: [Buffer];
|
|
||||||
disconnected: [];
|
|
||||||
dlr: [Dlr, PduObject];
|
|
||||||
incomingPdu: [Buffer];
|
|
||||||
incomingPduObj: [PduObject];
|
|
||||||
messageDlr: [MessageDlr];
|
|
||||||
reconnected: [];
|
|
||||||
sessionError: [Error | PduRefusedError];
|
|
||||||
sms: [Sms];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bindCommands: readonly string[] = [
|
|
||||||
'bind_receiver',
|
|
||||||
'bind_transceiver',
|
|
||||||
'bind_transmitter',
|
|
||||||
];
|
|
||||||
|
|
||||||
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
|
|
||||||
|
|
||||||
/** Which end of the link a session is. Only `server()` is the SMSC; everything else is the ESME. */
|
|
||||||
export type LinkEnd = 'esme' | 'smsc';
|
|
||||||
|
|
||||||
export function bindTypeFromCommand(cmdName: string): BindType | undefined {
|
|
||||||
if (cmdName === 'bind_receiver') return 'receiver';
|
|
||||||
if (cmdName === 'bind_transceiver') return 'transceiver';
|
|
||||||
if (cmdName === 'bind_transmitter') return 'transmitter';
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which message-carrying command an inbound one stands in for. Every command but `data_sm` names
|
|
||||||
* its own direction; that one travels either way, so the end it arrived at is what says.
|
|
||||||
*/
|
|
||||||
export function standsInFor(cmdName: string, linkEnd: LinkEnd): string {
|
|
||||||
if (cmdName !== 'data_sm') return cmdName;
|
|
||||||
|
|
||||||
return linkEnd === 'smsc' ? 'submit_sm' : 'deliver_sm';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a
|
|
||||||
* transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that
|
|
||||||
* has not bound carries everything, since nothing has declared a direction yet.
|
|
||||||
*/
|
|
||||||
export function bindCarries(
|
|
||||||
bindType: BindType | undefined,
|
|
||||||
cmdName: string,
|
|
||||||
linkEnd: LinkEnd,
|
|
||||||
): boolean {
|
|
||||||
const carried = standsInFor(cmdName, linkEnd);
|
|
||||||
|
|
||||||
if (bindType === 'receiver') return carried !== 'submit_sm';
|
|
||||||
if (bindType === 'transmitter') return carried !== 'deliver_sm';
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SendOptions = { signal?: AbortSignal | undefined };
|
export type SendOptions = { signal?: AbortSignal | undefined };
|
||||||
|
|
||||||
@@ -115,25 +50,36 @@ export type SessionOptions = {
|
|||||||
systemId?: string | undefined;
|
systemId?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultSystemId = '';
|
|
||||||
|
|
||||||
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
|
|
||||||
export const undeclaredInterfaceVersion = 0x00;
|
|
||||||
|
|
||||||
export const defaults = {
|
export const defaults = {
|
||||||
|
bindType: 'transceiver',
|
||||||
|
connectTimeout: 10_000,
|
||||||
/** 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,
|
||||||
|
enquireLinkInterval: 20_000,
|
||||||
/** The peer gave up on an unanswered message long before this; the bound is against growth. */
|
/** The peer gave up on an unanswered message long before this; the bound is against growth. */
|
||||||
heldMessageTimeout: 300_000,
|
heldMessageTimeout: 300_000,
|
||||||
|
host: 'localhost',
|
||||||
|
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
|
||||||
|
idleTimeoutFactor: 2,
|
||||||
|
/** The version declared on the wire. */
|
||||||
|
interfaceVersion: 0x34,
|
||||||
|
maxDelay: 30_000,
|
||||||
maxDlrMerges: 1000,
|
maxDlrMerges: 1000,
|
||||||
maxHeldMessages: 1000,
|
maxHeldMessages: 1000,
|
||||||
|
maxHeldOctets: 64 * 1024 * 1024,
|
||||||
|
maxOctets: 64 * 1024 * 1024,
|
||||||
maxOutstanding: 10,
|
maxOutstanding: 10,
|
||||||
maxReassembly: 1000,
|
maxReassembly: 1000,
|
||||||
|
minDelay: 1000,
|
||||||
|
password: 'pass',
|
||||||
|
port: 2775,
|
||||||
reassemblyTimeout: 300_000,
|
reassemblyTimeout: 300_000,
|
||||||
responseTimeout: 30_000,
|
responseTimeout: 30_000,
|
||||||
|
serverIdleTimeout: 40_000,
|
||||||
shutdownTimeout: 5000,
|
shutdownTimeout: 5000,
|
||||||
systemId: defaultSystemId,
|
systemId: '',
|
||||||
};
|
username: 'user',
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send
|
* A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send
|
||||||
@@ -144,14 +90,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 +103,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 ?? defaults.maxOctets, 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) {
|
||||||
@@ -189,8 +162,8 @@ function checkReconnect(reconnect: unknown): VoidResult {
|
|||||||
return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) };
|
return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxDelay = delayOr(reconnect.maxDelay, backoffDefaults.maxDelay);
|
const maxDelay = delayOr(reconnect.maxDelay, defaults.maxDelay);
|
||||||
const minDelay = delayOr(reconnect.minDelay, backoffDefaults.minDelay);
|
const minDelay = delayOr(reconnect.minDelay, defaults.minDelay);
|
||||||
// A delay of 0 never doubles, so the backoff never starts and every retry lands at once.
|
// A delay of 0 never doubles, so the backoff never starts and every retry lands at once.
|
||||||
const checked = checkLimits([['maxDelay', maxDelay, 1], ['minDelay', minDelay, 1]]);
|
const checked = checkLimits([['maxDelay', maxDelay, 1], ['minDelay', minDelay, 1]]);
|
||||||
|
|
||||||
@@ -238,9 +211,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;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Result } from '../result.ts';
|
||||||
|
import { quoted } from '../result.ts';
|
||||||
|
|
||||||
|
export const bindCommands: readonly string[] = [
|
||||||
|
'bind_receiver',
|
||||||
|
'bind_transceiver',
|
||||||
|
'bind_transmitter',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
|
||||||
|
|
||||||
|
/** Which end of the link a session is. Only `server()` is the SMSC; everything else is the ESME. */
|
||||||
|
export type LinkEnd = 'esme' | 'smsc';
|
||||||
|
|
||||||
|
export function bindTypeFromCommand(cmdName: string): BindType | undefined {
|
||||||
|
if (cmdName === 'bind_receiver') return 'receiver';
|
||||||
|
if (cmdName === 'bind_transceiver') return 'transceiver';
|
||||||
|
if (cmdName === 'bind_transmitter') return 'transmitter';
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which message-carrying command an inbound one stands in for. Every command but `data_sm` names
|
||||||
|
* its own direction; that one travels either way, so the end it arrived at is what says.
|
||||||
|
*/
|
||||||
|
export function standsInFor(cmdName: string, linkEnd: LinkEnd): string {
|
||||||
|
if (cmdName !== 'data_sm') return cmdName;
|
||||||
|
|
||||||
|
return linkEnd === 'smsc' ? 'submit_sm' : 'deliver_sm';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a
|
||||||
|
* transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that
|
||||||
|
* has not bound carries everything, since nothing has declared a direction yet.
|
||||||
|
*/
|
||||||
|
export function bindCarries(
|
||||||
|
bindType: BindType | undefined,
|
||||||
|
cmdName: string,
|
||||||
|
linkEnd: LinkEnd,
|
||||||
|
): boolean {
|
||||||
|
const carried = standsInFor(cmdName, linkEnd);
|
||||||
|
|
||||||
|
if (bindType === 'receiver') return carried !== 'submit_sm';
|
||||||
|
if (bindType === 'transmitter') return carried !== 'deliver_sm';
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
|
||||||
|
export const undeclaredInterfaceVersion = 0x00;
|
||||||
|
|
||||||
|
export type SessionBind = { as: BindType; peerVersion: number };
|
||||||
|
|
||||||
|
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 } };
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ConcatInfo } from './udh.ts';
|
import type { ConcatInfo } from './udh.ts';
|
||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from '../codec/pdu.ts';
|
||||||
import { concatInfo } from './udh.ts';
|
import { concatInfo } from './udh.ts';
|
||||||
import { hasUdh } from './defs/constants.ts';
|
import { hasUdh } from '../codec/constants.ts';
|
||||||
import { messageOctets } from './message-body.ts';
|
import { messageOctets } from './message-body.ts';
|
||||||
import { paramNumber } from './defs/types.ts';
|
import { paramNumber } from '../codec/types.ts';
|
||||||
|
|
||||||
/** Where a segment sits in its message, and what ties it to the rest of that message. */
|
/** Where a segment sits in its message, and what ties it to the rest of that message. */
|
||||||
export type Concat = ConcatInfo & {
|
export type Concat = ConcatInfo & {
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { MessageState } from './defs/constants.ts';
|
import type { MessageState } from '../codec/constants.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { TlvValue } from '../codec/types.ts';
|
||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from '../codec/pdu.ts';
|
||||||
import type { SmsIdFormat } from './sms-id.ts';
|
import type { SmsIdFormat } from './message-ids.ts';
|
||||||
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
|
import { consts, constsById, hasUdh, messageTypeOf } from '../codec/constants.ts';
|
||||||
import { encodings } from './defs/encodings.ts';
|
import { encodings } from '../codec/encodings.ts';
|
||||||
import { messageOctets } from './message-body.ts';
|
import { messageOctets } from './message-body.ts';
|
||||||
import { normaliseSmsId } from './sms-id.ts';
|
import { normaliseSmsId } from './message-ids.ts';
|
||||||
import { paramNumber, paramText } from './defs/types.ts';
|
import { paramNumber, paramText } from '../codec/types.ts';
|
||||||
import { udhLength } from './udh.ts';
|
import { udhLength } from './udh.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() ?? ''];
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from '../codec/pdu.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user data, wherever the peer put it. SMPP 3.4 5.3.2.32 carries up to 64 KB in
|
* The user data, wherever the peer put it. SMPP 3.4 5.3.2.32 carries up to 64 KB in
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CommandName } from './defs/commands.ts';
|
import type { CommandName } from '../codec/commands.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { ParamValue } from '../codec/types.ts';
|
||||||
|
|
||||||
const notations = {
|
const notations = {
|
||||||
decimal: { digits: /^[0-9]+$/, prefix: '' },
|
decimal: { digits: /^[0-9]+$/, prefix: '' },
|
||||||
@@ -7,3 +7,26 @@ export type Result<T> =
|
|||||||
| ({ err?: undefined } & T);
|
| ({ err?: undefined } & T);
|
||||||
|
|
||||||
export type VoidResult = { err?: Error };
|
export type VoidResult = { err?: Error };
|
||||||
|
|
||||||
|
/** Whatever was thrown or rejected, as an Error. `String()` throws on some values; this cannot. */
|
||||||
|
export function errorFrom(reason: unknown): Error {
|
||||||
|
if (reason instanceof Error) return reason;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Error(String(reason));
|
||||||
|
} catch {
|
||||||
|
return new Error('A thrown value that cannot be converted to a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const printable: readonly string[] = ['boolean', 'number', 'string'];
|
||||||
|
|
||||||
|
/** String() throws on a null-prototype object, so anything but these is named by its type. */
|
||||||
|
export function namedValue(value: unknown): string {
|
||||||
|
return printable.includes(typeof value) ? String(value) : typeof value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A value named in an error: a string quoted, anything else as `namedValue()` names it. */
|
||||||
|
export function quoted(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? JSON.stringify(value) : namedValue(value);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import type { CloseOptions, OnRequest } from './session-options.ts';
|
import type { BindType } from '../protocol/bind.ts';
|
||||||
import type { PduObject, TlvInput } from './pdu.ts';
|
import type { CloseOptions, OnRequest } from '../options.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { PduObject, TlvInputs } from '../codec/pdu.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 } from '../session/session.ts';
|
||||||
import { bindTypeFromCommand, checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
|
import { bindTypeFromCommand } from '../protocol/bind.ts';
|
||||||
|
import { checkSessionOptions, defaults } from '../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 { errorFrom } from '../result.ts';
|
||||||
import { errorFrom } from './error-from.ts';
|
import { paramText } from '../codec/types.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { guardedLog } from '../log.ts';
|
||||||
import { guardedLog } from './log.ts';
|
import { respNameFor } from '../codec/commands.ts';
|
||||||
|
|
||||||
export type AuthenticateResult = { userData?: unknown } | boolean;
|
export type AuthenticateResult = { userData?: unknown } | boolean;
|
||||||
|
|
||||||
@@ -48,13 +50,6 @@ export type ServerEvents = {
|
|||||||
session: [Session];
|
session: [Session];
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaults = {
|
|
||||||
idleTimeout: 40_000,
|
|
||||||
interfaceVersion: defaultInterfaceVersion,
|
|
||||||
port: 2775,
|
|
||||||
systemId: defaultSystemId,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */
|
/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */
|
||||||
type ServerListener<K extends keyof ServerEvents> = (...args: ServerEvents[K]) => unknown;
|
type ServerListener<K extends keyof ServerEvents> = (...args: ServerEvents[K]) => unknown;
|
||||||
|
|
||||||
@@ -161,7 +156,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 +164,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 +180,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 +202,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');
|
||||||
@@ -235,7 +227,7 @@ async function handleRequest(
|
|||||||
function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): void {
|
function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): void {
|
||||||
const log = guardedLog(options.log);
|
const log = guardedLog(options.log);
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
idleTimeout: options.idleTimeout ?? defaults.idleTimeout,
|
idleTimeout: options.idleTimeout ?? defaults.serverIdleTimeout,
|
||||||
log,
|
log,
|
||||||
maxOutstanding: options.maxOutstanding,
|
maxOutstanding: options.maxOutstanding,
|
||||||
maxOctets: options.maxOctets,
|
maxOctets: options.maxOctets,
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { LinkLife } from './link-life.ts';
|
||||||
|
import type { PduObject, PduObjectInput } from '../codec/pdu.ts';
|
||||||
|
import type { Result } from '../result.ts';
|
||||||
|
import type { Session } from './session.ts';
|
||||||
|
import type { SmsHandlers } from './sms.ts';
|
||||||
|
import type { SmppLog } from '../log.ts';
|
||||||
|
import { ExpiringGroups } from '../messages/expiring-groups.ts';
|
||||||
|
import { IdleWaiters } from './idle-waiters.ts';
|
||||||
|
import { createSms } from './sms.ts';
|
||||||
|
import { retainedOctets } from '../codec/retained-pdu.ts';
|
||||||
|
|
||||||
|
export type HeldMessagesOptions = {
|
||||||
|
link: LinkLife;
|
||||||
|
log: SmppLog;
|
||||||
|
max: number;
|
||||||
|
maxOctets: number;
|
||||||
|
/** Injected so expiry can be exercised without a wall clock. */
|
||||||
|
now?: (() => number) | undefined;
|
||||||
|
sendPastDrain: SmsHandlers['send'];
|
||||||
|
session: Session;
|
||||||
|
timeout: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The peer's own sequence number, which is what our answer to this message will carry. */
|
||||||
|
function keyOf(pduObjs: PduObject[]): string | undefined {
|
||||||
|
const first = pduObjs[0];
|
||||||
|
|
||||||
|
return first ? String(first.seqNr) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HoldRoute = Pick<HeldMessagesOptions, 'link' | 'sendPastDrain' | 'session'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One message offered to the application, and the handlers its `Sms` answers through. A drain
|
||||||
|
* waits on it until the first of: `answered()`, every listener that took it rejecting, no listener
|
||||||
|
* taking it or one throwing, a later message on its sequence number, its deadline, or the link going.
|
||||||
|
*/
|
||||||
|
export class MessageHold implements SmsHandlers {
|
||||||
|
private readonly generation: number;
|
||||||
|
private readonly heldMessages: HeldMessages;
|
||||||
|
private readonly pduObjs: PduObject[];
|
||||||
|
private readonly route: HoldRoute;
|
||||||
|
private working: number;
|
||||||
|
|
||||||
|
constructor(heldMessages: HeldMessages, route: HoldRoute, pduObjs: PduObject[], listeners: number) {
|
||||||
|
this.generation = route.link.generation();
|
||||||
|
this.heldMessages = heldMessages;
|
||||||
|
this.pduObjs = pduObjs;
|
||||||
|
this.route = route;
|
||||||
|
this.working = listeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a drain is still waiting for this message to be answered. */
|
||||||
|
isHeld(): boolean {
|
||||||
|
return this.heldMessages.holds(this.pduObjs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A turn later, so a `sendDlr()` called straight after `sendResp()` still goes out past a drain. */
|
||||||
|
answered(): void {
|
||||||
|
setImmediate(() => { this.release(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
lostLink(): boolean {
|
||||||
|
return this.route.link.generation() !== this.generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 or a listener threw on: that is not work a shutdown can wait for. */
|
||||||
|
release(): void {
|
||||||
|
this.heldMessages.release(this.pduObjs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A receipt for a message still held is what a drain waits for, so it goes out past the drain. */
|
||||||
|
send(input: PduObjectInput): Promise<Result<{ pduObj: PduObject }>> {
|
||||||
|
return this.isHeld() ? this.route.sendPastDrain(input) : this.route.session.send(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The messages handed to the application that it has not answered yet, held by their segments. */
|
||||||
|
export class HeldMessages {
|
||||||
|
private readonly held: ExpiringGroups<PduObject[]>;
|
||||||
|
private readonly idleWaiters = new IdleWaiters();
|
||||||
|
private readonly log: SmppLog;
|
||||||
|
private readonly maxOctets: number;
|
||||||
|
/** A rejecting listener hands the message back as an `unknown`, so its hold is found by identity. */
|
||||||
|
private readonly offered = new WeakMap<object, MessageHold>();
|
||||||
|
private readonly route: HoldRoute;
|
||||||
|
|
||||||
|
constructor(options: HeldMessagesOptions) {
|
||||||
|
this.held = new ExpiringGroups({
|
||||||
|
max: options.max,
|
||||||
|
now: options.now,
|
||||||
|
onSweep: () => { this.sweep(); },
|
||||||
|
timeout: options.timeout,
|
||||||
|
});
|
||||||
|
this.log = options.log;
|
||||||
|
this.maxOctets = options.maxOctets;
|
||||||
|
this.route = { link: options.link, sendPastDrain: options.sendPastDrain, session: options.session };
|
||||||
|
}
|
||||||
|
|
||||||
|
get octetsHeld(): number {
|
||||||
|
return this.held.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.held.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a message arriving now is past the bound, once the expired are swept. */
|
||||||
|
full(): boolean {
|
||||||
|
this.sweep();
|
||||||
|
|
||||||
|
return this.held.full || this.held.weight >= this.maxOctets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hold(key: string, pduObjs: PduObject[], listeners: number): MessageHold {
|
||||||
|
const hold = new MessageHold(this, this.route, pduObjs, listeners);
|
||||||
|
|
||||||
|
this.sweep();
|
||||||
|
|
||||||
|
if (this.held.get(key)) {
|
||||||
|
this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.held.set(key, pduObjs);
|
||||||
|
this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0));
|
||||||
|
|
||||||
|
return hold;
|
||||||
|
}
|
||||||
|
|
||||||
|
offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | undefined {
|
||||||
|
const key = keyOf(pduObjs);
|
||||||
|
|
||||||
|
if (key === undefined) return undefined;
|
||||||
|
|
||||||
|
const hold = this.hold(key, pduObjs, this.route.session.listenerCount('sms'));
|
||||||
|
const sms = createSms({ answeredAs, pduObjs, session: this.route.session }, hold);
|
||||||
|
|
||||||
|
this.offered.set(sms, hold);
|
||||||
|
|
||||||
|
if (!this.route.session.emit('sms', sms)) hold.release();
|
||||||
|
|
||||||
|
return hold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One listener gave up on a message; the last one to do so is what releases it. */
|
||||||
|
listenerRejected(message: unknown): void {
|
||||||
|
if (typeof message !== 'object' || message === null) return;
|
||||||
|
|
||||||
|
this.offered.get(message)?.listenerGaveUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
holds(pduObjs: PduObject[]): boolean {
|
||||||
|
const key = keyOf(pduObjs);
|
||||||
|
|
||||||
|
return key !== undefined && this.held.get(key) === pduObjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
release(pduObjs: PduObject[]): void {
|
||||||
|
const key = keyOf(pduObjs);
|
||||||
|
|
||||||
|
// Identity, not the key: a wrapped sequence number must not release someone else's message.
|
||||||
|
if (key === undefined || this.held.get(key) !== pduObjs) return;
|
||||||
|
|
||||||
|
this.held.delete(key);
|
||||||
|
this.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */
|
||||||
|
clear(): void {
|
||||||
|
this.held.takeAll();
|
||||||
|
this.idleWaiters.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves 0 once every message has been answered, or with how many have not. */
|
||||||
|
idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
|
||||||
|
return this.idleWaiters.wait(() => this.held.size, timeout, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops every message past its deadline. Runs before each hold and on its own timer. */
|
||||||
|
sweep(): void {
|
||||||
|
const expired = this.held.takeExpired();
|
||||||
|
|
||||||
|
if (expired.length === 0) return;
|
||||||
|
|
||||||
|
this.log.warn('heldMessages - messages the application never answered', {
|
||||||
|
messages: expired.length,
|
||||||
|
});
|
||||||
|
this.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private settle(): void {
|
||||||
|
if (this.held.size === 0) this.idleWaiters.settle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
import type { Concat } from './concat.ts';
|
import type { Concat } from '../protocol/concat.ts';
|
||||||
import type { DlrMerger } from './dlr-merger.ts';
|
import type { DlrMerger } from '../messages/dlr-merger.ts';
|
||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { ErrorName } from '../codec/errors.ts';
|
||||||
import type { LostGroup, Refusal } from './reassembly.ts';
|
import type { HeldMessagesOptions } from './held-messages.ts';
|
||||||
import type { OnRequest } from './session-options.ts';
|
import type { LinkLife } from './link-life.ts';
|
||||||
import type { PduObject, PduObjectInput } from './pdu.ts';
|
import type { LostGroup, Refusal } from '../messages/reassembly.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { OnRequest } from '../options.ts';
|
||||||
|
import type { PduObject } from '../codec/pdu.ts';
|
||||||
|
import type { VoidResult } from '../result.ts';
|
||||||
import type { Session } from './session.ts';
|
import type { Session } from './session.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
import type { SmsIdFormat } from './sms-id.ts';
|
import type { SmsIdFormat } from '../protocol/message-ids.ts';
|
||||||
import { HeldMessages } from './held-messages.ts';
|
import { HeldMessages } from './held-messages.ts';
|
||||||
import { Reassembler, decodeSegments } from './reassembly.ts';
|
import { Reassembler } from '../messages/reassembly.ts';
|
||||||
import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
import { bindCommands, standsInFor } from '../protocol/bind.ts';
|
||||||
import { concatOf } from './concat.ts';
|
import { defaults } from '../options.ts';
|
||||||
import { createSms } from './sms.ts';
|
import { concatOf } from '../protocol/concat.ts';
|
||||||
import { dlrFromPdu } from './dlr.ts';
|
import { detach } from '../codec/retained-pdu.ts';
|
||||||
import { paramText } from './defs/types.ts';
|
import { dlrFromPdu } from '../protocol/dlr.ts';
|
||||||
import { respIdParams, segmentId } from './sms-id.ts';
|
import { respIdParams, segmentId } from '../protocol/message-ids.ts';
|
||||||
|
import { respNameFor } from '../codec/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 +35,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> = {
|
||||||
@@ -39,13 +46,13 @@ const lostReasons: Record<LostGroup['reason'], string> = {
|
|||||||
|
|
||||||
export type IncomingRequestsOptions = {
|
export type IncomingRequestsOptions = {
|
||||||
dlrMerger: DlrMerger;
|
dlrMerger: DlrMerger;
|
||||||
|
link: LinkLife;
|
||||||
log: SmppLog;
|
log: SmppLog;
|
||||||
maxOctets?: number | undefined;
|
maxOctets?: number | undefined;
|
||||||
maxReassembly?: number | undefined;
|
maxReassembly?: number | undefined;
|
||||||
onRequest?: OnRequest | 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: HeldMessagesOptions['sendPastDrain'];
|
||||||
sendPastDrain: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
|
||||||
session: Session;
|
session: Session;
|
||||||
smsIdFormat?: SmsIdFormat | undefined;
|
smsIdFormat?: SmsIdFormat | undefined;
|
||||||
systemId?: string | undefined;
|
systemId?: string | undefined;
|
||||||
@@ -54,25 +61,28 @@ export type IncomingRequestsOptions = {
|
|||||||
/** 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 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;
|
||||||
|
private readonly link: LinkLife;
|
||||||
private readonly log: SmppLog;
|
private readonly log: SmppLog;
|
||||||
private readonly onRequest: OnRequest | undefined;
|
private readonly onRequest: OnRequest | undefined;
|
||||||
private readonly reassembler: Reassembler;
|
private readonly reassembler: Reassembler;
|
||||||
private readonly sendPastDrain: IncomingRequestsOptions['sendPastDrain'];
|
|
||||||
private readonly session: Session;
|
private readonly session: Session;
|
||||||
private readonly smsIdFormat: SmsIdFormat;
|
private readonly smsIdFormat: SmsIdFormat;
|
||||||
private readonly systemId: string;
|
private readonly systemId: string;
|
||||||
private linkGeneration = 0;
|
private refusing = false;
|
||||||
|
|
||||||
constructor(options: IncomingRequestsOptions) {
|
constructor(options: IncomingRequestsOptions) {
|
||||||
this.dlrMerger = options.dlrMerger;
|
this.dlrMerger = options.dlrMerger;
|
||||||
this.held = new HeldMessages({
|
this.held = new HeldMessages({
|
||||||
|
link: options.link,
|
||||||
log: options.log,
|
log: options.log,
|
||||||
max: defaults.maxHeldMessages,
|
max: defaults.maxHeldMessages,
|
||||||
|
maxOctets: defaults.maxHeldOctets,
|
||||||
|
sendPastDrain: options.sendPastDrain,
|
||||||
|
session: options.session,
|
||||||
timeout: defaults.heldMessageTimeout,
|
timeout: defaults.heldMessageTimeout,
|
||||||
});
|
});
|
||||||
|
this.link = options.link;
|
||||||
this.log = options.log;
|
this.log = options.log;
|
||||||
this.onRequest = options.onRequest;
|
this.onRequest = options.onRequest;
|
||||||
this.reassembler = new Reassembler({
|
this.reassembler = new Reassembler({
|
||||||
@@ -82,19 +92,20 @@ 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.session = options.session;
|
||||||
this.smsIdFormat = options.smsIdFormat ?? {};
|
this.smsIdFormat = options.smsIdFormat ?? {};
|
||||||
this.systemId = options.systemId ?? defaults.systemId;
|
this.systemId = options.systemId ?? defaults.systemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handle(pduObj: PduObject): Promise<void> {
|
async handle(pduObj: PduObject): Promise<void> {
|
||||||
const generation = this.linkGeneration;
|
const generation = this.link.generation();
|
||||||
|
const { onRequest } = this;
|
||||||
|
|
||||||
if (this.onRequest && await this.onRequest(this.session, pduObj)) return;
|
// Called unbound, so the application's hook never sees this class as its `this`.
|
||||||
|
if (onRequest && await onRequest(this.session, 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.link.generation() !== generation) {
|
||||||
this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName });
|
this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -140,16 +151,13 @@ 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.refusing = false;
|
||||||
this.held.clear();
|
this.held.clear();
|
||||||
this.reassembler.clear();
|
this.reassembler.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One `sms` listener gave up on a message; the last one to do so is what releases the hold. */
|
|
||||||
listenerRejected(sms: unknown): void {
|
listenerRejected(sms: unknown): void {
|
||||||
if (typeof sms !== 'object' || sms === null) return;
|
this.held.listenerRejected(sms);
|
||||||
|
|
||||||
this.emitted.get(sms)?.();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -171,6 +179,12 @@ export class IncomingRequests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!respNameFor(pduObj.cmdName)) {
|
||||||
|
this.log.verbose('session - ignoring a command SMPP gives no response', { cmdName: pduObj.cmdName });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
|
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
|
||||||
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
|
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
|
||||||
}
|
}
|
||||||
@@ -198,15 +212,49 @@ export class IncomingRequests {
|
|||||||
await this.session.sendReturn(pduObj);
|
await this.session.sendReturn(pduObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refusedAtBound(pduObj: PduObject): Promise<boolean> {
|
||||||
|
if (this.held.full()) {
|
||||||
|
if (!this.refusing) {
|
||||||
|
this.refusing = true;
|
||||||
|
this.log.warn('session - unanswered messages at their bound, refusing new ones until the application answers', {
|
||||||
|
messages: this.held.size,
|
||||||
|
octets: this.held.octetsHeld,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.verbose('session - unanswered messages at their bound, asking the peer to retry', {
|
||||||
|
cmdName: pduObj.cmdName,
|
||||||
|
seqNr: pduObj.seqNr,
|
||||||
|
});
|
||||||
|
await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half, so a peer keeping its window full does not flip this on every answer.
|
||||||
|
if (
|
||||||
|
this.refusing
|
||||||
|
&& this.held.size <= defaults.maxHeldMessages / 2
|
||||||
|
&& this.held.octetsHeld <= defaults.maxHeldOctets / 2
|
||||||
|
) {
|
||||||
|
this.refusing = false;
|
||||||
|
this.log.info('session - unanswered messages down to half their bound, accepting again', { messages: this.held.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A concatenated message is answered segment by segment as it arrives: a peer that dispatches
|
* A concatenated message is answered segment by segment as it arrives: a peer that dispatches
|
||||||
* one request at a time never sends the second segment until the first has been answered.
|
* 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.held.offer([detach(pduObj)]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,7 +276,7 @@ export class IncomingRequests {
|
|||||||
respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)),
|
respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (collected.whole) this.emitSms(collected.whole, collected.smsId);
|
if (collected.whole) this.held.offer(collected.whole, collected.smsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private reportLost(lost: LostGroup): void {
|
private reportLost(lost: LostGroup): void {
|
||||||
@@ -236,41 +284,4 @@ export class IncomingRequests {
|
|||||||
`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]}`,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitSms(pduObjs: PduObject[], answeredAs?: string): void {
|
|
||||||
const first = pduObjs[0];
|
|
||||||
|
|
||||||
if (!first) return;
|
|
||||||
|
|
||||||
const generation = this.linkGeneration;
|
|
||||||
// A turn later, so a listener sending its receipt straight after the response still holds.
|
|
||||||
const release = (): void => { setImmediate(() => { this.held.release(pduObjs); }); };
|
|
||||||
|
|
||||||
const sms = createSms({
|
|
||||||
answeredAs,
|
|
||||||
from: paramText(first.params.source_addr),
|
|
||||||
message: decodeSegments(pduObjs),
|
|
||||||
pduObjs,
|
|
||||||
session: this.session,
|
|
||||||
to: paramText(first.params.destination_addr),
|
|
||||||
}, {
|
|
||||||
lostLink: () => this.linkGeneration !== generation,
|
|
||||||
onAnswered: release,
|
|
||||||
// Past the refusal only while a drain is still waiting for this message; an ordinary send after.
|
|
||||||
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.
|
|
||||||
let working = this.session.listenerCount('sms');
|
|
||||||
|
|
||||||
this.held.hold(pduObjs);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import type { SmppLog } from '../log.ts';
|
||||||
|
import type { VoidResult } from '../result.ts';
|
||||||
|
|
||||||
|
export type LinkLifeOptions = {
|
||||||
|
log: SmppLog;
|
||||||
|
now?: (() => number) | undefined;
|
||||||
|
/** Whether a dropped link is followed by another one until stop(). */
|
||||||
|
reconnects: boolean;
|
||||||
|
/** How long a request may wait for a link. 0 waits for as long as one may still arrive. */
|
||||||
|
timeout: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `binding`: a socket is attached and its bind is not answered yet, so it carries nothing but that bind. */
|
||||||
|
type Phase = 'binding' | 'down' | 'ended' | 'up';
|
||||||
|
|
||||||
|
type Waiter = (result: VoidResult) => void;
|
||||||
|
|
||||||
|
function aborted(): Error {
|
||||||
|
return new Error('Aborted while waiting for a link');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expired(): Error {
|
||||||
|
return new Error('The link did not come back in time');
|
||||||
|
}
|
||||||
|
|
||||||
|
function over(): Error {
|
||||||
|
return new Error('Session is closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the session's link lives, and where a request with no link to go out on waits for the next one. */
|
||||||
|
export class LinkLife {
|
||||||
|
private readonly log: SmppLog;
|
||||||
|
private readonly now: () => number;
|
||||||
|
private readonly reconnects: boolean;
|
||||||
|
private readonly timeout: number;
|
||||||
|
private readonly waiting = new Set<Waiter>();
|
||||||
|
private drops = 0;
|
||||||
|
private phase: Phase = 'up';
|
||||||
|
private stopped = false;
|
||||||
|
|
||||||
|
constructor(options: LinkLifeOptions) {
|
||||||
|
this.log = options.log;
|
||||||
|
this.now = options.now ?? Date.now;
|
||||||
|
this.reconnects = options.reconnects;
|
||||||
|
this.timeout = options.timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A socket is on the link, bound or not. */
|
||||||
|
isAttached(): boolean {
|
||||||
|
return this.phase === 'binding' || this.phase === 'up';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a request can go out right now. */
|
||||||
|
isUp(): boolean {
|
||||||
|
return this.phase === 'up';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOver(): boolean {
|
||||||
|
return this.phase === 'ended';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session is shutting down: nothing new is taken, and no link follows this one. */
|
||||||
|
isStopped(): boolean {
|
||||||
|
return this.stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a link that drops now is followed by another. */
|
||||||
|
retrying(): boolean {
|
||||||
|
return this.reconnects && !this.stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Not up and not over, with a link to come. */
|
||||||
|
awaitsNextLink(): boolean {
|
||||||
|
return !this.isUp() && !this.isOver() && this.retrying();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes with every drop, so what was read off one link can tell that link is gone. */
|
||||||
|
generation(): number {
|
||||||
|
return this.drops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why no request will ever be admitted, or undefined while one may still get through. */
|
||||||
|
refusal(): Error | undefined {
|
||||||
|
return this.isUp() || this.awaitsNextLink() ? undefined : over();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One budget for a request, however many links it waits through. */
|
||||||
|
hold(signal: AbortSignal | undefined): () => Promise<VoidResult> {
|
||||||
|
const deadline = this.timeout > 0 ? this.now() + this.timeout : 0;
|
||||||
|
|
||||||
|
return () => this.wait(deadline, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A socket from the reconnect loop, not yet bound. An ended session stays ended. */
|
||||||
|
attach(): void {
|
||||||
|
if (this.isOver()) return;
|
||||||
|
|
||||||
|
this.phase = 'binding';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The link is bound: everything held goes out on it. */
|
||||||
|
open(): void {
|
||||||
|
this.phase = 'up';
|
||||||
|
|
||||||
|
if (this.waiting.size > 0) {
|
||||||
|
this.log.verbose('linkLife - sending what was held for a link', { held: this.waiting.size });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.release({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The attached link is gone: the event that says so, or undefined when there was none to lose. */
|
||||||
|
drop(): 'close' | 'disconnected' | undefined {
|
||||||
|
if (!this.isAttached()) return undefined;
|
||||||
|
|
||||||
|
this.phase = 'down';
|
||||||
|
this.drops++;
|
||||||
|
|
||||||
|
return this.retrying() ? 'disconnected' : 'close';
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session is over: nothing held will ever go out. False means it already was. */
|
||||||
|
end(): boolean {
|
||||||
|
if (this.isOver()) return false;
|
||||||
|
|
||||||
|
this.phase = 'ended';
|
||||||
|
this.stopped = true;
|
||||||
|
this.release({ err: over() });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once a link can carry the request, or with the reason none ever will. */
|
||||||
|
private wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
||||||
|
if (this.isUp()) return Promise.resolve({});
|
||||||
|
|
||||||
|
const refused = this.refusal();
|
||||||
|
|
||||||
|
if (refused) return Promise.resolve({ err: refused });
|
||||||
|
|
||||||
|
if (signal?.aborted === true) return Promise.resolve({ err: aborted() });
|
||||||
|
|
||||||
|
const left = deadline === 0 ? 0 : deadline - this.now();
|
||||||
|
|
||||||
|
if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() });
|
||||||
|
|
||||||
|
return this.waitForLink(left, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForLink(left: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
||||||
|
this.log.verbose('linkLife - holding a request until a link is back', { timeout: left });
|
||||||
|
|
||||||
|
return new Promise<VoidResult>(resolve => {
|
||||||
|
let timer: NodeJS.Timeout | undefined = undefined;
|
||||||
|
const settle = (result: VoidResult): void => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
this.waiting.delete(settle);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const giveUp = (): void => {
|
||||||
|
this.log.warn('linkLife - no link came back in time', { timeout: left });
|
||||||
|
settle({ err: expired() });
|
||||||
|
};
|
||||||
|
|
||||||
|
function onAbort(): void {
|
||||||
|
settle({ err: aborted() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled.
|
||||||
|
if (left > 0) timer = setTimeout(giveUp, left);
|
||||||
|
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
this.waiting.add(settle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private release(result: VoidResult): void {
|
||||||
|
for (const settle of [...this.waiting]) {
|
||||||
|
settle(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
|
|
||||||
export type LinkTimersOptions = {
|
export type LinkTimersOptions = {
|
||||||
/** How long between enquire_link probes. Undefined or 0 never probes. */
|
/** How long between enquire_link probes. Undefined or 0 never probes. */
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import type { PduObject, PduObjectInput } from './pdu.ts';
|
import type { LinkLife } from './link-life.ts';
|
||||||
|
import type { PduObject, PduObjectInput } from '../codec/pdu.ts';
|
||||||
import type { PduTransport } from './pdu-transport.ts';
|
import type { PduTransport } from './pdu-transport.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from '../result.ts';
|
||||||
import type { SendOptions } from './session-options.ts';
|
import type { SendOptions } from '../options.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
import { LinkGate } from './link-gate.ts';
|
|
||||||
import { PendingRequests } from './pending-requests.ts';
|
import { PendingRequests } from './pending-requests.ts';
|
||||||
import { SendWindow } from './send-window.ts';
|
import { SendWindow } from './send-window.ts';
|
||||||
import { UnansweredError } from './unanswered-error.ts';
|
import { UnansweredError } from '../unanswered-error.ts';
|
||||||
import { bindCommands } from './session-options.ts';
|
import { bindCommands } from '../protocol/bind.ts';
|
||||||
import { objToPdu } from './pdu.ts';
|
import { objToPdu } from '../codec/pdu.ts';
|
||||||
|
|
||||||
export type OutgoingRequestsOptions = {
|
export type OutgoingRequestsOptions = {
|
||||||
|
link: LinkLife;
|
||||||
log: SmppLog;
|
log: SmppLog;
|
||||||
maxOutstanding: number;
|
maxOutstanding: number;
|
||||||
responseTimeout: number;
|
responseTimeout: number;
|
||||||
@@ -33,17 +34,15 @@ function misuse(input: PduObjectInput): Error | undefined {
|
|||||||
|
|
||||||
/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */
|
/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */
|
||||||
export class OutgoingRequests {
|
export class OutgoingRequests {
|
||||||
private readonly gate: LinkGate;
|
private readonly link: LinkLife;
|
||||||
private readonly log: SmppLog;
|
private readonly log: SmppLog;
|
||||||
private readonly pending: PendingRequests;
|
private readonly pending: PendingRequests;
|
||||||
private readonly responseTimeout: number;
|
private readonly responseTimeout: number;
|
||||||
private readonly transport: PduTransport;
|
private readonly transport: PduTransport;
|
||||||
private readonly window: SendWindow;
|
private readonly window: SendWindow;
|
||||||
|
|
||||||
private draining = false;
|
|
||||||
|
|
||||||
constructor(options: OutgoingRequestsOptions) {
|
constructor(options: OutgoingRequestsOptions) {
|
||||||
this.gate = new LinkGate({ log: options.log, timeout: options.responseTimeout });
|
this.link = options.link;
|
||||||
this.log = options.log;
|
this.log = options.log;
|
||||||
this.pending = new PendingRequests(options.log);
|
this.pending = new PendingRequests(options.log);
|
||||||
this.responseTimeout = options.responseTimeout;
|
this.responseTimeout = options.responseTimeout;
|
||||||
@@ -51,19 +50,12 @@ 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.link.isUp() && !this.transport.sock.destroyed;
|
||||||
return !this.gate.isUp() || this.transport.sock.destroyed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A link is up and bound, so everything held for one goes out on it. */
|
/** The link is gone, and every answer still owed on it with it. */
|
||||||
linkUp(): void {
|
linkLost(): void {
|
||||||
this.gate.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The link is gone; `returning` says whether another one is on its way. */
|
|
||||||
linkLost(returning: boolean): void {
|
|
||||||
this.gate.shut(returning);
|
|
||||||
this.pending.settleAll(new Error('Session closed before a response arrived'));
|
this.pending.settleAll(new Error('Session closed before a response arrived'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,23 +69,22 @@ export class OutgoingRequests {
|
|||||||
this.pending.settle(seqNr, { err });
|
this.pending.settle(seqNr, { err });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sends a request and resolves with the peer's response. */
|
|
||||||
request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
|
request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
|
||||||
// Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown.
|
// Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown.
|
||||||
const wrong = misuse(input);
|
const wrong = misuse(input);
|
||||||
|
|
||||||
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.link.isStopped() && 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 }>> {
|
||||||
@@ -101,14 +92,14 @@ export class OutgoingRequests {
|
|||||||
|
|
||||||
if (refused) return { err: refused };
|
if (refused) return { err: refused };
|
||||||
|
|
||||||
// A bind is what makes a link usable, so it cannot wait for one: it takes the gate's answer now.
|
// A bind is what makes a link usable, so it cannot wait for one: it takes the link's answer now.
|
||||||
if (bindCommands.includes(input.cmdName)) {
|
if (bindCommands.includes(input.cmdName)) {
|
||||||
const shut = this.gate.refusal();
|
const shut = this.link.refusal();
|
||||||
|
|
||||||
return shut ? { err: shut } : this.now(input, options);
|
return shut ? { err: shut } : this.requestOnCurrentLink(input, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
const waitForLink = this.gate.hold(options.signal);
|
const waitForLink = this.link.hold(options.signal);
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const held = await waitForLink();
|
const held = await waitForLink();
|
||||||
@@ -121,21 +112,18 @@ 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 requestOnCurrentLink(
|
||||||
|
input: PduObjectInput,
|
||||||
|
options: SendOptions = {},
|
||||||
|
): Promise<Result<{ pduObj: PduObject }>> {
|
||||||
return (await this.attempt(input, options)).result;
|
return (await this.attempt(input, options)).result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refuses every request from here on, on a link that is already down as much as a live one. */
|
|
||||||
stopAccepting(): void {
|
|
||||||
this.draining = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Waits out the requests already on the wire, and says how many never finished. */
|
/** Waits out the requests already on the wire, and says how many never finished. */
|
||||||
async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
||||||
const unfinished = await this.window.idle(timeout, signal);
|
const unfinished = await this.window.idle(timeout, signal);
|
||||||
@@ -147,9 +135,15 @@ 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. */
|
||||||
|
private retriesOnNextLink(attempt: Attempt): boolean {
|
||||||
|
// Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins.
|
||||||
|
return attempt.retryOnNextLink && this.link.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 link and the window, or an aborted call waits for what it will never use.
|
||||||
return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined);
|
return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from '../codec/pdu.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
import type { VoidResult } from './result.ts';
|
import type { VoidResult } from '../result.ts';
|
||||||
import { PduFramer } from './pdu-framer.ts';
|
import { PduFramer } from '../codec/pdu-framer.ts';
|
||||||
import { PduRefusedError } from './pdu-refusal.ts';
|
import { PduRefusedError } from '../codec/refusal.ts';
|
||||||
import { pduToObj } from './pdu.ts';
|
import { pduToObj } from '../codec/pdu.ts';
|
||||||
|
|
||||||
export type PduTransportOptions = {
|
export type PduTransportOptions = {
|
||||||
log: SmppLog;
|
log: SmppLog;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from '../codec/pdu.ts';
|
||||||
import type { Result } from './result.ts';
|
import type { Result } from '../result.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
import { maxSeqNr } from './pdu.ts';
|
import { maxSeqNr } from '../codec/pdu.ts';
|
||||||
|
|
||||||
export type WaitOptions = {
|
export type WaitOptions = {
|
||||||
signal?: AbortSignal | undefined;
|
signal?: AbortSignal | undefined;
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from '../result.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
|
import { defaults } from '../options.ts';
|
||||||
export const backoffDefaults = {
|
|
||||||
maxDelay: 30_000,
|
|
||||||
minDelay: 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ReconnectLoopOptions = {
|
export type ReconnectLoopOptions = {
|
||||||
connect: () => Promise<Result<{ sock: Socket }>>;
|
connect: () => Promise<Result<{ sock: Socket }>>;
|
||||||
@@ -32,15 +28,15 @@ export class ReconnectLoop {
|
|||||||
private upAt: number | undefined;
|
private upAt: number | undefined;
|
||||||
|
|
||||||
constructor(options: ReconnectLoopOptions) {
|
constructor(options: ReconnectLoopOptions) {
|
||||||
this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay;
|
this.maxDelay = options.maxDelay ?? defaults.maxDelay;
|
||||||
this.minDelay = options.minDelay ?? backoffDefaults.minDelay;
|
this.minDelay = options.minDelay ?? defaults.minDelay;
|
||||||
this.now = options.now ?? Date.now;
|
this.now = options.now ?? Date.now;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.delay = this.minDelay;
|
this.delay = this.minDelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read through a method: stop() can land while an attempt is awaiting. */
|
/** Read through a method: stop() can land while an attempt is awaiting. */
|
||||||
isStopped(): boolean {
|
private isStopped(): boolean {
|
||||||
return this.halted;
|
return this.halted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from '../log.ts';
|
||||||
import type { VoidResult } from './result.ts';
|
import type { VoidResult } from '../result.ts';
|
||||||
import { IdleWaiters } from './idle-waiters.ts';
|
import { IdleWaiters } from './idle-waiters.ts';
|
||||||
|
|
||||||
export type SendWindowOptions = {
|
export type SendWindowOptions = {
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { Dlr } from '../protocol/dlr.ts';
|
||||||
import type { MessageDlr } from './dlr-merger.ts';
|
import type { ErrorName } from '../codec/errors.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { MessageDlr } from '../messages/dlr-merger.ts';
|
||||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
import type { ParamValue } from '../codec/types.ts';
|
||||||
import type { PduRefusedError } from './pdu-refusal.ts';
|
import type { PduObject, PduObjectInput, TlvInputs } from '../codec/pdu.ts';
|
||||||
import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
|
import type { PduRefusedError } from '../codec/refusal.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { BindType, LinkEnd, SessionBind } from '../protocol/bind.ts';
|
||||||
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
import type { CloseOptions, ReconnectOptions, SendOptions, SessionOptions } from '../options.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { Result, VoidResult } from '../result.ts';
|
||||||
|
import type { SendSmsOptions, SendSmsResult } from '../messages/submit.ts';
|
||||||
|
import type { SmppLog } from '../log.ts';
|
||||||
|
import type { Sms } from './sms.ts';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
import { DlrMerger } from './dlr-merger.ts';
|
import { DlrMerger } from '../messages/dlr-merger.ts';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { IncomingRequests } from './incoming-requests.ts';
|
import { IncomingRequests } from './incoming-requests.ts';
|
||||||
|
import { LinkLife } from './link-life.ts';
|
||||||
import { LinkTimers } from './link-timers.ts';
|
import { LinkTimers } from './link-timers.ts';
|
||||||
import { OutgoingRequests } from './outgoing-requests.ts';
|
import { OutgoingRequests } from './outgoing-requests.ts';
|
||||||
import { PduTransport } from './pdu-transport.ts';
|
import { PduTransport } from './pdu-transport.ts';
|
||||||
import { ReconnectLoop } from './reconnect-loop.ts';
|
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 '../result.ts';
|
||||||
import { optionalParamsMinVersion } from './defs/constants.ts';
|
import { optionalParamsMinVersion } from '../codec/constants.ts';
|
||||||
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts';
|
import { bindCarries, checkedBind } from '../protocol/bind.ts';
|
||||||
import { isResp, objToPdu, pduReturn } from './pdu.ts';
|
import { defaults } from '../options.ts';
|
||||||
import { refusalAnswer } from './pdu-refusal.ts';
|
import { isResp, objToPdu, pduReturn } from '../codec/pdu.ts';
|
||||||
import { guardedLog } from './log.ts';
|
import { refusalAnswer } from '../codec/refusal.ts';
|
||||||
import { submitSms, unsent } from './send-sms.ts';
|
import { guardedLog } from '../log.ts';
|
||||||
import { ConcatReference } from './udh.ts';
|
import { submitSms, unsent } from '../messages/submit.ts';
|
||||||
|
import { ConcatReference } from '../protocol/udh.ts';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CloseOptions,
|
CloseOptions,
|
||||||
@@ -32,11 +37,22 @@ export type {
|
|||||||
SendOptions,
|
SendOptions,
|
||||||
SendSmsOptions,
|
SendSmsOptions,
|
||||||
SendSmsResult,
|
SendSmsResult,
|
||||||
SessionEvents,
|
|
||||||
SessionOptions,
|
SessionOptions,
|
||||||
};
|
};
|
||||||
export type { BindType };
|
export type { BindType };
|
||||||
export { bindCommands, defaultSystemId };
|
|
||||||
|
export type SessionEvents = {
|
||||||
|
close: [];
|
||||||
|
data: [Buffer];
|
||||||
|
disconnected: [];
|
||||||
|
dlr: [Dlr, PduObject];
|
||||||
|
incomingPdu: [Buffer];
|
||||||
|
incomingPduObj: [PduObject];
|
||||||
|
messageDlr: [MessageDlr];
|
||||||
|
reconnected: [];
|
||||||
|
sessionError: [Error | PduRefusedError];
|
||||||
|
sms: [Sms];
|
||||||
|
};
|
||||||
|
|
||||||
/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */
|
/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */
|
||||||
type SessionListener<K extends keyof SessionEvents> = (...args: SessionEvents[K]) => unknown;
|
type SessionListener<K extends keyof SessionEvents> = (...args: SessionEvents[K]) => unknown;
|
||||||
@@ -52,27 +68,22 @@ 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;
|
||||||
|
private readonly link: LinkLife;
|
||||||
private readonly options: SessionOptions;
|
private readonly options: SessionOptions;
|
||||||
private readonly outgoing: OutgoingRequests;
|
private readonly outgoing: OutgoingRequests;
|
||||||
private readonly reconnectLoop: ReconnectLoop | undefined;
|
private readonly reconnectLoop: ReconnectLoop | undefined;
|
||||||
private readonly timers: LinkTimers;
|
private readonly timers: LinkTimers;
|
||||||
private readonly transport: PduTransport;
|
private readonly transport: PduTransport;
|
||||||
|
|
||||||
private closed = false;
|
|
||||||
private ended = false;
|
|
||||||
|
|
||||||
/** 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>(
|
||||||
event: K,
|
event: K,
|
||||||
@@ -112,24 +123,12 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
|
|
||||||
this.log = guardedLog(options.log);
|
this.log = guardedLog(options.log);
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.dlrMerger = new DlrMerger({
|
this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout });
|
||||||
log: this.log,
|
|
||||||
max: defaults.maxDlrMerges,
|
|
||||||
timeout: defaults.dlrMergeTimeout,
|
|
||||||
});
|
|
||||||
this.incoming = new IncomingRequests({
|
|
||||||
dlrMerger: this.dlrMerger,
|
|
||||||
log: this.log,
|
|
||||||
maxOctets: options.maxOctets,
|
|
||||||
maxReassembly: options.maxReassembly,
|
|
||||||
onRequest: options.onRequest,
|
|
||||||
reassemblyTimeout: options.reassemblyTimeout,
|
|
||||||
sendPastDrain: input => this.outgoing.pastDrain(input, {}),
|
|
||||||
session: this,
|
|
||||||
smsIdFormat: options.smsIdFormat,
|
|
||||||
systemId: options.systemId,
|
|
||||||
});
|
|
||||||
this.reconnectLoop = this.loopFor(options.reconnect);
|
this.reconnectLoop = this.loopFor(options.reconnect);
|
||||||
|
|
||||||
|
const responseTimeout = options.responseTimeout ?? defaults.responseTimeout;
|
||||||
|
|
||||||
|
this.link = new LinkLife({ log: this.log, reconnects: this.reconnectLoop !== undefined, timeout: responseTimeout });
|
||||||
this.timers = new LinkTimers({
|
this.timers = new LinkTimers({
|
||||||
enquireLinkInterval: options.enquireLinkInterval,
|
enquireLinkInterval: options.enquireLinkInterval,
|
||||||
idleTimeout: options.idleTimeout,
|
idleTimeout: options.idleTimeout,
|
||||||
@@ -140,11 +139,25 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
});
|
});
|
||||||
this.transport = this.transportFor(options.sock);
|
this.transport = this.transportFor(options.sock);
|
||||||
this.outgoing = new OutgoingRequests({
|
this.outgoing = new OutgoingRequests({
|
||||||
|
link: this.link,
|
||||||
log: this.log,
|
log: this.log,
|
||||||
maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding,
|
maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding,
|
||||||
responseTimeout: options.responseTimeout ?? defaults.responseTimeout,
|
responseTimeout,
|
||||||
transport: this.transport,
|
transport: this.transport,
|
||||||
});
|
});
|
||||||
|
this.incoming = new IncomingRequests({
|
||||||
|
dlrMerger: this.dlrMerger,
|
||||||
|
link: this.link,
|
||||||
|
log: this.log,
|
||||||
|
maxOctets: options.maxOctets,
|
||||||
|
maxReassembly: options.maxReassembly,
|
||||||
|
onRequest: options.onRequest,
|
||||||
|
reassemblyTimeout: options.reassemblyTimeout,
|
||||||
|
sendPastDrain: input => this.outgoing.requestPastDrain(input, {}),
|
||||||
|
session: this,
|
||||||
|
smsIdFormat: options.smsIdFormat,
|
||||||
|
systemId: options.systemId,
|
||||||
|
});
|
||||||
|
|
||||||
this.resetTimers();
|
this.resetTimers();
|
||||||
}
|
}
|
||||||
@@ -154,6 +167,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 +193,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 +202,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.link.isAttached()) {
|
||||||
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 +250,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.link.isAttached();
|
||||||
// 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.requestOnCurrentLink({ cmdName: 'unbind' })
|
||||||
: { err: new Error('Session is closed') };
|
: { err: new Error('Session is closed') };
|
||||||
const closedOnUnbind = wasOpen && this.closed;
|
const closedOnUnbind = wasOpen && !this.link.isAttached();
|
||||||
|
|
||||||
this.end();
|
this.end();
|
||||||
|
|
||||||
@@ -234,8 +262,8 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closes for good: refuses new sends, waits out the requests already on the wire up to
|
* Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent
|
||||||
* `shutdownTimeout`, then tears down whatever is left. A session closed this way never reconnects.
|
* and the messages not yet answered, then tears down whatever is left. A session closed this way never reconnects.
|
||||||
*/
|
*/
|
||||||
async close(options: CloseOptions = {}): Promise<VoidResult> {
|
async close(options: CloseOptions = {}): Promise<VoidResult> {
|
||||||
const drained = await this.drain(options.signal);
|
const drained = await this.drain(options.signal);
|
||||||
@@ -288,14 +316,14 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// close() can land while the rebind is in flight.
|
// close() can land while the rebind is in flight.
|
||||||
if (this.reconnectLoop?.isStopped() === true) {
|
if (!this.link.retrying()) {
|
||||||
this.teardown();
|
this.teardown();
|
||||||
|
|
||||||
return { err: new Error('Session closed while it was coming back up') };
|
return { err: new Error('Session closed while it was coming back up') };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.resetTimers();
|
this.resetTimers();
|
||||||
this.outgoing.linkUp();
|
this.link.open();
|
||||||
this.log.info('session - reconnected');
|
this.log.info('session - reconnected');
|
||||||
this.emit('reconnected');
|
this.emit('reconnected');
|
||||||
|
|
||||||
@@ -304,15 +332,15 @@ 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.link.attach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
|
private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
|
||||||
this.reconnectLoop?.stop();
|
this.stop();
|
||||||
this.outgoing.stopAccepting();
|
|
||||||
|
|
||||||
if (this.outgoing.linkDown()) return {};
|
// No bound 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 +348,8 @@ 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.
|
// The link went before the drain finished, so an empty window says nothing about the peer.
|
||||||
if (this.outgoing.linkDown()) {
|
if (!this.outgoing.canCarry()) {
|
||||||
return { err: new Error('The session closed before the drain finished') };
|
return { err: new Error('The session closed before the drain finished') };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,41 +371,40 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
|
|
||||||
/** The session is over now, drained or not. Nothing brings it back. */
|
/** The session is over now, drained or not. Nothing brings it back. */
|
||||||
private end(): void {
|
private end(): void {
|
||||||
this.reconnectLoop?.stop();
|
this.stop();
|
||||||
this.teardown();
|
this.teardown();
|
||||||
this.dlrMerger.clear();
|
this.dlrMerger.clear();
|
||||||
this.emitClose();
|
this.emitClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitClose(): void {
|
/** No new sends, and no link after this one. */
|
||||||
if (this.ended) return;
|
private stop(): void {
|
||||||
|
this.link.stop();
|
||||||
|
this.reconnectLoop?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
this.ended = true;
|
private emitClose(): void {
|
||||||
this.outgoing.linkLost(false);
|
if (!this.link.end()) return;
|
||||||
|
|
||||||
|
this.outgoing.linkLost();
|
||||||
this.emit('close');
|
this.emit('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
private teardown(): void {
|
private teardown(): void {
|
||||||
if (this.closed) return;
|
const lost = this.link.drop();
|
||||||
|
|
||||||
this.closed = true;
|
if (!lost) return;
|
||||||
|
|
||||||
// Read once: clear() reports lost segments, and a listener could stop the loop between reads.
|
this.outgoing.linkLost();
|
||||||
const retrying = this.retrying();
|
|
||||||
|
|
||||||
this.outgoing.linkLost(retrying);
|
|
||||||
this.timers.clear();
|
this.timers.clear();
|
||||||
this.incoming.clear();
|
this.incoming.clear();
|
||||||
this.sock.destroy();
|
this.sock.destroy();
|
||||||
|
|
||||||
if (retrying) this.emit('disconnected');
|
// `lost` is read before clear(): a listener it reaches may close() the session, and the drop still reports as disconnected.
|
||||||
|
if (lost === 'disconnected') this.emit('disconnected');
|
||||||
else this.emitClose();
|
else this.emitClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private retrying(): boolean {
|
|
||||||
return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped();
|
|
||||||
}
|
|
||||||
|
|
||||||
private onData(chunk: Buffer): void {
|
private onData(chunk: Buffer): void {
|
||||||
this.emit('data', chunk);
|
this.emit('data', chunk);
|
||||||
this.resetTimers();
|
this.resetTimers();
|
||||||
@@ -423,15 +450,15 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resetTimers(): void {
|
private resetTimers(): void {
|
||||||
if (this.closed) return;
|
if (!this.link.isAttached()) return;
|
||||||
|
|
||||||
this.timers.reset();
|
this.timers.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onClose(): void {
|
private onClose(): void {
|
||||||
if (this.reconnectLoop && !this.reconnectLoop.isStopped()) {
|
if (this.link.retrying()) {
|
||||||
this.teardown();
|
this.teardown();
|
||||||
this.reconnectLoop.schedule();
|
this.reconnectLoop?.schedule();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { ErrorName } from '../codec/errors.ts';
|
||||||
import type { MessageState } from './defs/constants.ts';
|
import type { MessageState } from '../codec/constants.ts';
|
||||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
import type { PduObject, PduObjectInput, TlvInputs } from '../codec/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';
|
||||||
import { consts } from './defs/constants.ts';
|
import { consts } from '../codec/constants.ts';
|
||||||
import { messageClassOf } from './defs/encodings.ts';
|
import { decodeSegments } from '../messages/reassembly.ts';
|
||||||
import { receiptCodes, transientStates } from './dlr.ts';
|
import { messageClassOf } from '../codec/encodings.ts';
|
||||||
import { smppDate } from './message.ts';
|
import { paramText } from '../codec/types.ts';
|
||||||
import { respIdParams, segmentId } from './sms-id.ts';
|
import { receiptCodes, transientStates } from '../protocol/dlr.ts';
|
||||||
import { uuidv7 } from './uuid.ts';
|
import { smppDate } from '../message.ts';
|
||||||
|
import { respIdParams, segmentId } from '../protocol/message-ids.ts';
|
||||||
|
import { uuidv7 } from '../protocol/uuid.ts';
|
||||||
|
|
||||||
/** `pduObjs` holds what the peer took, so a partial failure names what is already receipted. */
|
/** `pduObjs` holds what the peer took, so a partial failure names what is already receipted. */
|
||||||
export type SendDlrResult = {
|
export type SendDlrResult = {
|
||||||
@@ -59,17 +61,13 @@ export type Sms = {
|
|||||||
export type SmsInput = {
|
export type SmsInput = {
|
||||||
/** The id base the segments were already answered with; absent leaves the answer to `sendResp()`. */
|
/** The id base the segments were already answered with; absent leaves the answer to `sendResp()`. */
|
||||||
answeredAs?: string | undefined;
|
answeredAs?: string | undefined;
|
||||||
from: string;
|
|
||||||
message: string;
|
|
||||||
pduObjs: PduObject[];
|
pduObjs: PduObject[];
|
||||||
session: Session;
|
session: Session;
|
||||||
to: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** What the session's incoming side gives a message so it can be answered and accounted for. */
|
|
||||||
export type SmsHandlers = {
|
export type SmsHandlers = {
|
||||||
|
answered: () => void;
|
||||||
lostLink: () => boolean;
|
lostLink: () => boolean;
|
||||||
onAnswered: () => void;
|
|
||||||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,19 +84,19 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
|||||||
answeredOnArrival: input.answeredAs !== undefined,
|
answeredOnArrival: input.answeredAs !== undefined,
|
||||||
dlr: typeof registered === 'number' && registered !== 0,
|
dlr: typeof registered === 'number' && registered !== 0,
|
||||||
flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass,
|
flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass,
|
||||||
from: input.from,
|
from: paramText(first?.params.source_addr),
|
||||||
message: input.message,
|
message: decodeSegments(input.pduObjs),
|
||||||
pduObjs: input.pduObjs,
|
pduObjs: input.pduObjs,
|
||||||
sendDlr: status => sendDlr(sms, handlers.send, status),
|
sendDlr: status => sendDlr(sms, input.session, handlers, status),
|
||||||
sendResp: options => (input.answeredAs === undefined
|
sendResp: options => (input.answeredAs === undefined
|
||||||
? sendResp(sms, answered, options ?? {}, handlers)
|
? sendResp(sms, input.session, answered, options ?? {}, handlers)
|
||||||
: answeredOnArrival(options ?? {}, handlers)),
|
: answeredOnArrival(options ?? {}, handlers)),
|
||||||
session: input.session,
|
session: input.session,
|
||||||
get smsId(): string {
|
get smsId(): string {
|
||||||
return answered.smsId;
|
return answered.smsId;
|
||||||
},
|
},
|
||||||
submitTime: new Date(),
|
submitTime: new Date(),
|
||||||
to: input.to,
|
to: paramText(first?.params.destination_addr),
|
||||||
};
|
};
|
||||||
|
|
||||||
return sms;
|
return sms;
|
||||||
@@ -107,7 +105,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
|||||||
/** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */
|
/** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */
|
||||||
function answeredOnArrival(
|
function answeredOnArrival(
|
||||||
options: SendRespOptions,
|
options: SendRespOptions,
|
||||||
handlers: Pick<SmsHandlers, 'onAnswered'>,
|
handlers: Pick<SmsHandlers, 'answered'>,
|
||||||
): Promise<VoidResult> {
|
): Promise<VoidResult> {
|
||||||
if (options.smsId !== undefined) {
|
if (options.smsId !== undefined) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -121,16 +119,17 @@ function answeredOnArrival(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handlers.onAnswered();
|
handlers.answered();
|
||||||
|
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendResp(
|
async function sendResp(
|
||||||
sms: Sms,
|
sms: Sms,
|
||||||
|
session: Session,
|
||||||
answered: { smsId: string },
|
answered: { smsId: string },
|
||||||
options: SendRespOptions,
|
options: SendRespOptions,
|
||||||
handlers: Pick<SmsHandlers, 'lostLink' | 'onAnswered'>,
|
handlers: Pick<SmsHandlers, 'answered' | 'lostLink'>,
|
||||||
): Promise<VoidResult> {
|
): Promise<VoidResult> {
|
||||||
const total = sms.pduObjs.length;
|
const total = sms.pduObjs.length;
|
||||||
|
|
||||||
@@ -149,7 +148,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) => session.sendReturn(
|
||||||
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)),
|
||||||
@@ -157,7 +156,7 @@ async function sendResp(
|
|||||||
|
|
||||||
const failure = results.find(result => result.err);
|
const failure = results.find(result => result.err);
|
||||||
|
|
||||||
if (!failure) handlers.onAnswered();
|
if (!failure) handlers.answered();
|
||||||
|
|
||||||
return failure ?? {};
|
return failure ?? {};
|
||||||
}
|
}
|
||||||
@@ -179,7 +178,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 +209,11 @@ function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult {
|
|||||||
|
|
||||||
async function sendDlr(
|
async function sendDlr(
|
||||||
sms: Sms,
|
sms: Sms,
|
||||||
send: SmsHandlers['send'],
|
session: Session,
|
||||||
|
handlers: Pick<SmsHandlers, 'send'>,
|
||||||
status: MessageState = 'DELIVERED',
|
status: MessageState = 'DELIVERED',
|
||||||
): Promise<SendDlrResult> {
|
): Promise<SendDlrResult> {
|
||||||
if (!sms.session.bindAllows('deliver_sm')) {
|
if (!session.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 +226,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 +236,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) } : {}),
|
...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}),
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
return collectReceipt(sent);
|
return collectReceipt(sent);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { PduParams, PduParamsInput } from '../src/defs/commands.ts';
|
import type { PduParams, PduParamsInput } from '../src/codec/commands.ts';
|
||||||
import { cmds, cmdsById, commandNameById, isCommandName } from '../src/defs/commands.ts';
|
import { cmds, cmdsById, commandNameById, isCommandName } from '../src/codec/commands.ts';
|
||||||
|
|
||||||
describe('command table', () => {
|
describe('command table', () => {
|
||||||
test('every command is reachable by name and by id', () => {
|
test('every command is reachable by name and by id', () => {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
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 { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client/client.ts';
|
||||||
import { closeAfter } from './teardown.ts';
|
import { closeAfter } from './teardown.ts';
|
||||||
import { consts } from '../src/defs/constants.ts';
|
import { consts } from '../src/codec/constants.ts';
|
||||||
import { decodeMessage } from '../src/message.ts';
|
import { decodeMessage } from '../src/message.ts';
|
||||||
import { dlrFromPdu } from '../src/dlr.ts';
|
import { dlrFromPdu } from '../src/protocol/dlr.ts';
|
||||||
import { encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
|
import { encodingByDataCoding, encodings } from '../src/codec/encodings.ts';
|
||||||
import { objToPdu, pduToObj } from '../src/pdu.ts';
|
import { objToPdu, pduToObj } from '../src/codec/pdu.ts';
|
||||||
import { paramNumber } from '../src/defs/types.ts';
|
import { paramNumber } from '../src/codec/types.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
import type { PduObject } from '../src/pdu.ts';
|
import type { PduObject } from '../src/codec/pdu.ts';
|
||||||
|
|
||||||
const from = '46701113311';
|
const from = '46701113311';
|
||||||
const to = '46709771337';
|
const to = '46709771337';
|
||||||
|
|||||||
+5
-5
@@ -1,10 +1,10 @@
|
|||||||
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 { consts } from '../src/defs/constants.ts';
|
import { consts } from '../src/codec/constants.ts';
|
||||||
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts';
|
import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/protocol/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/codec/pdu.ts';
|
||||||
import type { PduObject, TlvInput } from '../src/pdu.ts';
|
import type { PduObject, TlvInputs } from '../src/codec/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 {
|
||||||
|
|||||||
+6
-6
@@ -1,13 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { TestContext } from 'node:test';
|
import type { TestContext } from 'node:test';
|
||||||
import { PduFramer } from '../src/pdu-framer.ts';
|
import { PduFramer } from '../src/codec/pdu-framer.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client/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/codec/constants.ts';
|
||||||
import { objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
|
import { objToPdu, pduReturn, pduToObj } from '../src/codec/pdu.ts';
|
||||||
import { uuidv7 } from '../src/uuid.ts';
|
import { uuidv7 } from '../src/protocol/uuid.ts';
|
||||||
|
|
||||||
export type DummySmsc = {
|
export type DummySmsc = {
|
||||||
/** Writes a delivery receipt to the ESME, its body spelled as the test names it. */
|
/** Writes a delivery receipt to the ESME, its body spelled as the test names it. */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts';
|
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/codec/encodings.ts';
|
||||||
|
|
||||||
describe('ASCII (GSM 03.38)', () => {
|
describe('ASCII (GSM 03.38)', () => {
|
||||||
const samples: [string, number[]][] = [
|
const samples: [string, number[]][] = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { errorFrom } from '../src/error-from.ts';
|
import { errorFrom } from '../src/result.ts';
|
||||||
|
|
||||||
test('carries an Error through and describes anything else, including what String() refuses', () => {
|
test('carries an Error through and describes anything else, including what String() refuses', () => {
|
||||||
const original = new Error('the original');
|
const original = new Error('the original');
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import assert from 'node:assert/strict';
|
|||||||
import test, { describe } from 'node:test';
|
import test, { describe } from 'node:test';
|
||||||
import reference from 'smpp';
|
import reference from 'smpp';
|
||||||
import type { ReferenceSession } from 'smpp';
|
import type { ReferenceSession } from 'smpp';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client/client.ts';
|
||||||
import { closeAfter } from './teardown.ts';
|
import { closeAfter } from './teardown.ts';
|
||||||
import { concatInfo } from '../src/udh.ts';
|
import { concatInfo } from '../src/protocol/udh.ts';
|
||||||
import { objToPdu, pduToObj } from '../src/pdu.ts';
|
import { objToPdu, pduToObj } from '../src/codec/pdu.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
import { splitMessage } from '../src/message.ts';
|
import { splitMessage } from '../src/message.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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' },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -1,19 +1,19 @@
|
|||||||
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 { PduObjectInput } from '../src/pdu.ts';
|
import type { PduObjectInput } from '../src/codec/pdu.ts';
|
||||||
import type { SendSmsDeps } from '../src/send-sms.ts';
|
import type { SendSmsDeps } from '../src/messages/submit.ts';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { Sms } from '../src/sms.ts';
|
import type { Sms } from '../src/session/sms.ts';
|
||||||
import type { TestContext } from 'node:test';
|
import type { TestContext } from 'node:test';
|
||||||
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client/client.ts';
|
||||||
import { closeAfter } from './teardown.ts';
|
import { closeAfter } from './teardown.ts';
|
||||||
import { messageClassOf } from '../src/defs/encodings.ts';
|
import { messageClassOf } from '../src/codec/encodings.ts';
|
||||||
import { paramNumber } from '../src/defs/types.ts';
|
import { paramNumber } from '../src/codec/types.ts';
|
||||||
import { pduToObj } from '../src/pdu.ts';
|
import { pduToObj } from '../src/codec/pdu.ts';
|
||||||
import { server } from '../src/server.ts';
|
import { server } from '../src/server/server.ts';
|
||||||
import { silentLog } from '../src/log.ts';
|
import { silentLog } from '../src/log.ts';
|
||||||
import { submitSms } from '../src/send-sms.ts';
|
import { submitSms } from '../src/messages/submit.ts';
|
||||||
|
|
||||||
const from = '46701113311';
|
const from = '46701113311';
|
||||||
const to = '46709771337';
|
const to = '46709771337';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 { EncodingName } from '../src/defs/encodings.ts';
|
import type { EncodingName } from '../src/codec/encodings.ts';
|
||||||
import {
|
import {
|
||||||
bitCount,
|
bitCount,
|
||||||
decodeMessage,
|
decodeMessage,
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
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 { PduObjectInput } from '../src/pdu.ts';
|
import type { PduObjectInput } from '../src/codec/pdu.ts';
|
||||||
import type { SendSmsDeps } from '../src/send-sms.ts';
|
import type { SendSmsDeps } from '../src/messages/submit.ts';
|
||||||
import type { Session } from '../src/session.ts';
|
import type { Session } from '../src/session/session.ts';
|
||||||
import type { SubmitMessagingMode } from '../src/defs/constants.ts';
|
import type { SubmitMessagingMode } from '../src/codec/constants.ts';
|
||||||
import type { TestContext } from 'node:test';
|
import type { TestContext } from 'node:test';
|
||||||
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
|
||||||
import { consts, submitMessagingModes } from '../src/defs/constants.ts';
|
import { consts, submitMessagingModes } from '../src/codec/constants.ts';
|
||||||
import { paramNumber } from '../src/defs/types.ts';
|
import { paramNumber } from '../src/codec/types.ts';
|
||||||
import { pduToObj } from '../src/pdu.ts';
|
import { pduToObj } from '../src/codec/pdu.ts';
|
||||||
import { silentLog } from '../src/log.ts';
|
import { silentLog } from '../src/log.ts';
|
||||||
import { submitSms } from '../src/send-sms.ts';
|
import { submitSms } from '../src/messages/submit.ts';
|
||||||
|
|
||||||
const from = '46701113311';
|
const from = '46701113311';
|
||||||
const to = '46709771337';
|
const to = '46709771337';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user