576b713af4
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
6205 lines
257 KiB
Diff
6205 lines
257 KiB
Diff
diff --git a/AGENTS.md b/AGENTS.md
|
||
index 882bb24..06cc397 100644
|
||
--- a/AGENTS.md
|
||
+++ b/AGENTS.md
|
||
@@ -40,14 +40,16 @@ src/
|
||
index.ts Public surface. Named exports only, no default export.
|
||
client.ts client() -> { err, session }
|
||
server.ts server() -> { err, server }, server owns the listener + close()
|
||
- session.ts Session: the socket's life, dispatch, events, and the collaborators below
|
||
- sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
|
||
+ session.ts Session: the link's life (linkLost, finish, comeBackUp), the drain, dispatch and events
|
||
+ sms.ts The live handle handed to onSms (sendResp/sendDlr)
|
||
+ bind-direction.ts Bind types and commands, which end of the link this is, what a bind direction carries
|
||
concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs
|
||
+ defaults.ts Every default the session layer runs on, in one object
|
||
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
|
||
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
|
||
error-from.ts An untyped value as error material: errorFrom() an Error, namedValue() a name
|
||
- expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger, HeldMessages and Reassembler share
|
||
- held-messages.ts HeldMessages: a message from its `sms` event to its answer, capped and expiring, one MessageHold each
|
||
+ expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger, HandledMessages and Reassembler share
|
||
+ handled-messages.ts HandledMessages: the messages whose onSms handler is running, capped, expiring, waited on by a drain
|
||
idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget
|
||
incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
|
||
link-life.ts LinkLife: whether the link lives, and where a request waits for the next one
|
||
@@ -55,7 +57,7 @@ src/
|
||
log.ts SmppLog, the logger contract, and silentLog — the default
|
||
message.ts Encoding detection, splitting, bit counting, SMPP date formatting
|
||
message-body.ts Where an inbound body is: short_message, or the message_payload TLV
|
||
- outgoing-requests.ts OutgoingRequests: the window, the pending map and the retry
|
||
+ outgoing-requests.ts OutgoingRequests: request() through the link wait and the window, requestOnLink() for a bind or unbind
|
||
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
|
||
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
|
||
pdu-refusal.ts A PDU the codec would not read, and the answer SMPP names for it
|
||
@@ -67,7 +69,7 @@ src/
|
||
retained-pdu.ts A PDU copied off the wire so holding it pins nothing else, and what holding it costs
|
||
send-sms.ts submitSms composition and the submitSmParams builder
|
||
send-window.ts SendWindow: the maxOutstanding semaphore
|
||
- session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
|
||
+ session-options.ts SessionOptions, ReconnectOptions, OnSms, OnRequest, and the option checks
|
||
sms-id.ts Message ids: the peer's notation, the <base>-<n> a segment gets, which response carries one
|
||
udh.ts User data header: its length, the concatenation fields of a long SMS and their reference
|
||
unanswered-error.ts UnansweredError: it went out and no answer came back
|
||
@@ -84,8 +86,10 @@ src/
|
||
|
||
Imports point one way: `defs` knows nothing above it but `result.ts`, `pdu` uses `defs`, `session`
|
||
uses `pdu`, and `client`/`server` use `session`. The ways back up are the `Session` handed to
|
||
-`createSms()`, `HeldMessages` and `IncomingRequests`, which call back into it, and to `OnRequest`
|
||
-and `onConnected` in `session-options.ts`, all imported as a type only.
|
||
+`IncomingRequests` (the hook's argument, the bind predicates, `emit` and `close`) and to
|
||
+`createSms()` (the public `sms.session`), and to `OnRequest`, `OnSms` and `onConnected` in
|
||
+`session-options.ts`, all imported as a type only. Everything else a collaborator does to the
|
||
+session is a named closure in its options: `answer`, `sendReceipt`, `report`.
|
||
|
||
**Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are
|
||
written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering
|
||
@@ -224,6 +228,7 @@ this is not a changelog.
|
||
|
||
### [The public surface](docs/decisions.md#the-public-surface)
|
||
|
||
+- A request the application must answer is a handler option; a fact it may watch is an event.
|
||
- `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
|
||
public too.
|
||
- `acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.
|
||
@@ -288,15 +293,15 @@ this is not a changelog.
|
||
- A stream this library cannot frame is a dead link; one PDU it cannot parse is not.
|
||
- A deliberate shutdown drains; an unusable link and an abort do not.
|
||
- `sendSms()` puts every segment of a message on the wire together.
|
||
-- 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.
|
||
+- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one writes
|
||
+ nothing.
|
||
- `server()` composes the application's `onRequest` after its own bind handling, and offers it every
|
||
request that handling did not answer.
|
||
-- The drain waits on the messages the application holds, and `sendResp()` is what says it is done
|
||
- with one.
|
||
-- The drain's wait on the application ignores `shutdownTimeout: 0`.
|
||
-- What the application holds unanswered is capped on constants, and a message past the cap is
|
||
- refused.
|
||
+- The drain waits on the `onSms` handlers still running, and a handler's promise is what says it
|
||
+ is done with a message.
|
||
+- A handler that fails before answering has the message refused for it; one that fails after has
|
||
+ its answer stand.
|
||
+- What the running handlers hold is capped on constants, and a message past the cap is refused.
|
||
- 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.
|
||
- A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.
|
||
diff --git a/CHANGELOG.md b/CHANGELOG.md
|
||
index 7cb5ff7..ab02284 100644
|
||
--- a/CHANGELOG.md
|
||
+++ b/CHANGELOG.md
|
||
@@ -2,6 +2,25 @@
|
||
|
||
## 0.6.0 (unreleased)
|
||
|
||
+- **Inbound messages go to an `onSms` handler option, and the `sms` event is gone.** Give it to
|
||
+ `client()`, `server()` or `Session`; `sms.session` says which session a server's message came in
|
||
+ on. The message is held — counted toward the bound, and waited for by `close()` — until the
|
||
+ promise the handler returns settles. A handler that throws or rejects before answering has the
|
||
+ message refused for it (`ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery), so the
|
||
+ peer retries; with no `onSms` at all, every message is refused that way and reported on
|
||
+ `sessionError`. The release used to come from `sendResp()`, one turn later, or from every
|
||
+ listener rejecting.
|
||
+- `close()` and `unbind()` refuse new `sendSms()` and `send()` calls, and let `sendResp()` and
|
||
+ `sendDlr()` through for as long as the session lives, so a receipt sent after the answer goes out
|
||
+ wherever in the handler it is sent. `shutdownTimeout: 0` now waits for a handler as long as it
|
||
+ waits for a request; it used to fall back to `responseTimeout` for the messages.
|
||
+- `sendResp()` refuses a second call on the same message, and a call that failed leaves `sms.smsId`
|
||
+ as it was.
|
||
+- `encoding: 'GSM'` names the GSM 03.38 alphabet, in `sendSms()`, `EncodingName`, `encodings`,
|
||
+ `dataCodingByEncoding`, `detect()` and the rest. It was `'ASCII'`, which named the one thing the
|
||
+ alphabet is not.
|
||
+- A client whose rebind was answered on a link the peer had already dropped no longer comes up on
|
||
+ the dead socket; the reconnect loop retries instead.
|
||
- `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
|
||
@@ -42,9 +61,9 @@
|
||
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.
|
||
+ delivery), so the peer keeps it and retries. **Return from `onSms` on every message, multipart
|
||
+ included**: 1000 handlers still running now stop inbound traffic for up to five minutes, where the
|
||
+ oldest message 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
|
||
diff --git a/DESIGN.md b/DESIGN.md
|
||
new file mode 100644
|
||
index 0000000..66a8b5d
|
||
--- /dev/null
|
||
+++ b/DESIGN.md
|
||
@@ -0,0 +1,83 @@
|
||
+# Draft D: the message handler is the contract
|
||
+
|
||
+Round one showed the internals were as simple as the `sms` event allowed: a hold with six exits,
|
||
+three of them about how many listeners there were and how they failed. This draft changes the
|
||
+contract so the hold has one exit, and the internals follow.
|
||
+
|
||
+## The contract, as an implementer sees it
|
||
+
|
||
+- **Receiving.** `onSms: sms => Promise<void> | void`, an option on `client()`, `server()` and
|
||
+ `Session`. One handler per session; a server's handler sees every session, `sms.session` says which.
|
||
+- **Answering.** `await sms.sendResp()` once, `sendResp({ smsId, status })` to name or refuse. A
|
||
+ multipart message was answered per segment as it arrived (`sms.answeredOnArrival`), so there it
|
||
+ writes nothing. A second call is refused. `sms.sendDlr()` sends the receipt, before or after the
|
||
+ handler returns.
|
||
+- **What happens if you don't.** Return without answering: the peer waits on you, the shutdown does
|
||
+ not. Throw or reject before answering: the message is refused with the retry status and reported
|
||
+ on `sessionError`. Throw after answering: the answer stands. No `onSms`: every message is refused
|
||
+ the same way. Never return: after five minutes the message stops counting.
|
||
+- **Bound.** 1000 running handlers, or 64 MiB of messages under them, refuse new messages until half.
|
||
+- **Sending.** `sendSms()`/`send()` wait for a bound link and a window slot; what never reached the
|
||
+ socket waits for the next link, what did is `unanswered`. Unchanged.
|
||
+- **Shutdown.** `close()`/`unbind()`: refuse new `sendSms()`/`send()`; `sendResp()`/`sendDlr()`
|
||
+ still go out. Wait up to `shutdownTimeout` for running handlers, then for requests on the wire.
|
||
+ `0` waits forever for both. Tear down, report what did not finish.
|
||
+- **Events** are facts nothing waits on: `dlr`, `messageDlr`, `close`, `disconnected`,
|
||
+ `reconnected`, `sessionError`, `data`, `incomingPdu`, `incomingPduObj`. Rule in README: a request
|
||
+ you must answer is a handler; a fact you may watch is an event.
|
||
+- `encoding: 'GSM'` names GSM 03.38.
|
||
+
|
||
+## Internal structure and ownership
|
||
+
|
||
+| Module | Owns |
|
||
+| --- | --- |
|
||
+| `session.ts` | The link's life in three methods: `linkLost()` (socket close, unreadable stream, idle, failed rebind — one entry, decides retry or end), `finish()` (once; drop, clear merges, `close`), `comeBackUp()`. `drain()` is the shutdown in eleven lines. Collaborators get named closures (`answer`, `sendReceipt`, `report`), the session itself only where the public contract needs it (`sms.session`, the hook's argument). |
|
||
+| `handled-messages.ts` | `HandledMessages`: the whole hold. `offer()` creates the `Sms`, runs the handler, releases on settle; answers a failed handler's message; `refuses()` with its hysteresis; `idle()` for the drain; `sweep()`/`clear()`. |
|
||
+| `incoming-requests.ts` | Routing only, and synchronous but for the hook and `close()`: hook, link-gone check, bind gate, one switch, reassembly, offer. |
|
||
+| `outgoing-requests.ts` | `request()`: one path (refusal, link wait, slot, attempt, retry where nothing reached the socket), with `pastDrain` for a receipt. `requestOnLink()`: a bind or unbind, straight onto the attached socket, outside the window. Binds are routed there by `request()` so `send()` still carries a hand-wired bind. |
|
||
+| `sms.ts` | `sendResp()` marks the message answered synchronously before writing, so a second call and a failed handler both find it; a failed write leaves the id alone. `SmsHandlers` is `answer`/`answered`/`lostLink`/`send`, all explicit. |
|
||
+| `bind-direction.ts` | Bind types, commands (derived from one list), `LinkEnd`, `standsInFor`, `bindCarries`, `checkedBind`; out of `session-options.ts`, which keeps options and their checks. |
|
||
+| `defaults.ts` | Every default, one object; `client.ts`, `server.ts`, `reassembly.ts`, `reconnect-loop.ts` read it. |
|
||
+
|
||
+The other modules are unchanged in shape; `DlrMerger.close()` is `spend()`.
|
||
+
|
||
+## Deleted
|
||
+
|
||
+`held-messages.ts` (`HeldMessages`, `MessageHold`, the `WeakMap`, the `setImmediate`, the listener
|
||
+count, the re-used-sequence-number exit); `Session[captureRejectionSymbol]`'s routing into the hold;
|
||
+`IncomingRequests.listenerRejected()` and its `refusing` flag; `OutgoingRequests.requestPastDrain()`
|
||
+and `requestOnCurrentLink()` (folded into `request()` + `requestOnLink()`); `Session.end()`,
|
||
+`stop()`, `emitClose()`, `teardown()`, `onClose()`, `attach()`, `resetTimers()`, `answering()` and
|
||
+the `shutdownTimeout: 0` fallback; three `defaults` objects, `backoffDefaults`, `defaultMaxOctets`,
|
||
+`defaultSystemId`'s re-export; `checkHooks()` in `server.ts` (now in `checkSessionOptions()`); the
|
||
+`sms` key of `SessionEvents`; `EncodingName` `'ASCII'`. Also fixed on the way: a rebind answered on
|
||
+a link the peer had dropped no longer opens the dead socket (todo item, now in `comeBackUp()`).
|
||
+
|
||
+## Tests
|
||
+
|
||
+`docker compose run --rm node npm test`: lint and typecheck clean, **520 tests, 520 pass, 0 fail**
|
||
+(baseline 515). Every `session.on('sms', …)` in the suite became an `onSms` option (a test helper
|
||
+`inbox()` hands the first message to the test and holds nothing; `holding()` and `stuck` hold). None
|
||
+weakened on the wire or on goal 2. Changed beyond that mechanical move:
|
||
+
|
||
+- `graceful shutdown`: `submitInFlight()` holds through the handler and returns `release()`. Replaced
|
||
+ `gives up on a message the application never answers` → `…a handler that never returns`;
|
||
+ `falls back to responseTimeout …` → `waits out a handler for as long as it takes at shutdownTimeout 0`
|
||
+ (the fallback is gone); `a listener that rejected before answering …` → `a handler that rejected before
|
||
+ answering has the message refused for it, and holds nothing` (asserts the peer's `ESME_RTHROTTLED`);
|
||
+ `waits for the listener still working when another one rejected` (no second listener exists) → three
|
||
+ tests: `close() keeps waiting for a handler that answered and is still working`, `close() stops waiting
|
||
+ once the handler returns, answered or not`, `a handler that rejected after answering leaves that answer alone`;
|
||
+ `a message no listener took …` → `… no handler takes is refused at once …`; `leaves a message the library
|
||
+ refused to answer unanswered` → `keeps waiting on a handler whose answer the library refused`;
|
||
+ tests that relied on "no listener" to keep a request in flight use a `stuck` handler. Messages read
|
||
+ `still being handled` where they read `unanswered`.
|
||
+- `held message bounds` → `handled message bounds`: same bounds, exits are now handler return, sweep,
|
||
+ clear; added `answers for a handler that failed before answering, and reports it` and `answers for a
|
||
+ message no handler takes`.
|
||
+- `sendResp()`: added `answers once, and keeps the id it was given only once that answer is on the wire`.
|
||
+- `refuses to answer a message whose link went, held or already answered`: the already-answered
|
||
+ message is now refused as `already answered` (the held one still as link gone).
|
||
+- `turns a throwing sms listener …` and siblings, and `session-error.test.ts`: listener → handler.
|
||
+- `encodings`/`message`/`unsendable`/`message-class`/`declared-alphabet`: `'ASCII'` → `'GSM'`.
|
||
+- `readme.test.ts` follows the README examples; `incomingOn()` passes `answer` and `sendReceipt`.
|
||
diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md
|
||
new file mode 100644
|
||
index 0000000..3c2968a
|
||
--- /dev/null
|
||
+++ b/MIGRATION-NOTES.md
|
||
@@ -0,0 +1,29 @@
|
||
+# Breaking changes for a 0.5.0 user
|
||
+
|
||
+Each line names the change and its one-line replacement.
|
||
+
|
||
+- **The `sms` event is gone; inbound messages go to an `onSms` handler option.**
|
||
+ `session.on('sms', async sms => { … })` → `client({ onSms: async sms => { … } })`.
|
||
+- **A server's handler is a server option, not a per-session listener.**
|
||
+ `smpp.on('session', s => s.on('sms', h))` → `server({ onSms: h })`; `sms.session` is the session.
|
||
+- **A hand-wired `Session` takes the handler the same way.**
|
||
+ `session.on('sms', h)` → `new Session({ onSms: h, sock })`.
|
||
+- **The hold on a message is the handler's promise, not `sendResp()`.** `close()` waits until your
|
||
+ handler returns; do the answer and the receipt inside it. A handler that returns before answering
|
||
+ releases the hold; the message then waits on you, not on the shutdown.
|
||
+- **A handler that throws or rejects before answering has the message refused for it**
|
||
+ (`ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery), so the peer retries. Catch
|
||
+ what you want answered otherwise. Rejected listeners used to leave the message unanswered.
|
||
+- **No `onSms` means every inbound message is refused that way and reported on `sessionError`.** A
|
||
+ transceiver that only sends sees `dlr` events as before; give it `onSms` only if it receives.
|
||
+- **`sendDlr()` no longer needs to follow `sendResp()` in the same turn to pass a shutdown.** Send it
|
||
+ wherever in the handler you like; nothing to replace.
|
||
+- **`shutdownTimeout: 0` waits for a handler as long as it takes**, where it used to give the
|
||
+ messages `responseTimeout`. Set `shutdownTimeout` if you want a bound.
|
||
+- **`sendResp()` twice on one message is refused** with `This message was already answered`.
|
||
+ Answer once.
|
||
+- **`sendResp()` that fails leaves `sms.smsId` unchanged.** Read `sms.smsId` after a successful
|
||
+ answer, not before.
|
||
+- **`encoding: 'ASCII'` is `encoding: 'GSM'`**, and so are `encodings.GSM`, `dataCodingByEncoding.GSM`
|
||
+ and what `detect()` and `encodingByDataCoding()` return. `'ASCII'` is refused by name.
|
||
+- **`SessionEvents` has no `sms` key**; a listener typed against it moves to `OnSms`.
|
||
diff --git a/MIGRATION.md b/MIGRATION.md
|
||
index 0296ede..29f9d76 100644
|
||
--- a/MIGRATION.md
|
||
+++ b/MIGRATION.md
|
||
@@ -11,6 +11,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
|
||
`close()`, or the socket outlives the call.
|
||
- **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and
|
||
a `session` event. It no longer calls back once per connection.
|
||
+- **The `sms` event is the `onSms` option**, on `client()` and `server()` alike:
|
||
+ `onSms: async sms => { await sms.sendResp(); }`. The message is held until the handler returns.
|
||
- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the
|
||
id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
|
||
Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under
|
||
@@ -85,7 +87,7 @@ have for these:
|
||
directions. 0.4.0 kept only the last one it read.
|
||
- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a
|
||
`data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach
|
||
- the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer.
|
||
+ the application now: a receipt as `dlr`, answered for you, and a message to `onSms` for you to answer.
|
||
- A long message segmented by the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum`
|
||
TLVs rather than a user data header was never reassembled, so each segment arrived as its own
|
||
message. Both spellings reassemble now.
|
||
diff --git a/README.md b/README.md
|
||
index 9ff2b7d..2cbbe73 100644
|
||
--- a/README.md
|
||
+++ b/README.md
|
||
@@ -10,7 +10,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
|
||
- **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC.
|
||
- **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings.
|
||
- **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given.
|
||
-- **Graceful shutdown.** `close()` waits for what is in flight, so neither end has to guess.
|
||
+- **Graceful shutdown.** `close()` waits for your message handlers and for what is in flight, so
|
||
+ neither end has to guess.
|
||
- **Never throws.** Every fallible call resolves to `{ err?, … }`.
|
||
- **Interoperable.** Tested as a client against Jasmin and SMPPSim, and as a server against Kannel,
|
||
jsmpp, Cloudhopper, python-smpplib and php-smpp:
|
||
@@ -92,34 +93,37 @@ receipts into one, and SMSCs that write ids in two notations: [Delivery receipts
|
||
|
||
## Receive SMS
|
||
|
||
-A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events:
|
||
+A `receiver` or `transceiver` client hands mobile-originated messages to its `onSms` handler:
|
||
|
||
```javascript
|
||
-session.on('sms', async sms => {
|
||
- // sms.from, sms.to, sms.message
|
||
- await sms.sendResp();
|
||
+const { err, session } = await client({
|
||
+ onSms: async sms => {
|
||
+ // sms.from, sms.to, sms.message
|
||
+ await sms.sendResp();
|
||
+ },
|
||
});
|
||
```
|
||
|
||
-Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound
|
||
-past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A
|
||
-multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there
|
||
-puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth).
|
||
+Call `sendResp()` once for every message, multipart included; a message you never answer is one
|
||
+the peer waits on. The message is held for as long as your handler runs: it counts toward the bound
|
||
+past which the peer's messages are refused, and `close()` waits for the handler to return. A handler
|
||
+that throws or rejects before answering has the message refused for it, so the peer retries; without
|
||
+an `onSms` at all, every message is refused that way. Delivery receipts reach you as `dlr` events,
|
||
+not here. A multipart message arrives reassembled and already answered segment by segment, so
|
||
+`sendResp()` there puts nothing on the wire: [Receiving in depth](#receiving-in-depth).
|
||
|
||
## Run an SMPP server
|
||
|
||
```javascript
|
||
import { server } from '@larvit/smpp';
|
||
|
||
-const { err, server: smpp } = await server();
|
||
-if (err) throw err;
|
||
-
|
||
-smpp.on('session', session => {
|
||
- session.on('sms', async sms => {
|
||
- // sms.from, sms.to, sms.message, sms.dlr
|
||
+const { err, server: smpp } = await server({
|
||
+ onSms: async sms => {
|
||
+ // sms.from, sms.to, sms.message, sms.dlr, sms.session
|
||
await sms.sendResp();
|
||
- });
|
||
+ },
|
||
});
|
||
+if (err) throw err;
|
||
```
|
||
|
||
With authentication and delivery reports:
|
||
@@ -134,13 +138,10 @@ const { err, server: smpp } = await server({
|
||
|
||
return { userData: { userId: 123 } };
|
||
},
|
||
-});
|
||
-if (err) throw err;
|
||
-
|
||
-smpp.on('session', session => {
|
||
- session.on('sms', async sms => {
|
||
+ onSms: async sms => {
|
||
+ // sms.session.userData is what authenticate returned for this peer
|
||
if (sms.answeredOnArrival) {
|
||
- await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain
|
||
+ await sms.sendResp(); // multipart: already answered per segment, so this writes nothing
|
||
} else {
|
||
// no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' })
|
||
await sms.sendResp();
|
||
@@ -149,19 +150,22 @@ smpp.on('session', session => {
|
||
if (sms.dlr) {
|
||
await sms.sendDlr(); // same as sms.sendDlr('DELIVERED')
|
||
}
|
||
- });
|
||
+ },
|
||
});
|
||
+if (err) throw err;
|
||
|
||
console.log(smpp.port); // the port actually bound, useful when 0 was requested
|
||
await smpp.close(); // stop listening, then drain and close every live session
|
||
```
|
||
|
||
- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id.
|
||
- `sendResp({ smsId, status })` names the id or refuses the message.
|
||
+ `sendResp({ smsId, status })` names the id or refuses the message. A second call is refused.
|
||
- `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`,
|
||
`sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth).
|
||
- A message that arrived in several segments was answered as they arrived, so `sendResp()` there
|
||
takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
|
||
+- `smpp.close()` waits for every running `onSms` before it closes a session, so answer and send the
|
||
+ receipt inside the handler, as above, and nothing is cut off.
|
||
|
||
## Errors
|
||
|
||
@@ -183,7 +187,7 @@ Neither is named `error`, because Node throws on an unhandled `error` event.
|
||
| --- | --- | --- |
|
||
| A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. |
|
||
| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. |
|
||
-| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. |
|
||
+| The session or socket failing, or a hook, handler or listener that threw or rejected. | `Error` | Alert. |
|
||
|
||
The last two are told apart by message text only, so this alerts on both:
|
||
|
||
@@ -231,8 +235,9 @@ All optional. Timeouts and delays are milliseconds.
|
||
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
|
||
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
|
||
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
|
||
-| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. |
|
||
+| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for `onSms` handlers still running and for requests already sent. `0` waits forever: a request ends when the peer answers or `responseTimeout` expires, so both at `0` never ends, and a handler ends when it returns or after the five minutes past which one is no longer counted. |
|
||
| `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. |
|
||
+| `onSms` | refuse | `(sms) => Promise<void> \| void`. Takes every inbound message: [Receive SMS](#receive-sms). |
|
||
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). |
|
||
| `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. |
|
||
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). |
|
||
@@ -257,6 +262,7 @@ All optional. Timeouts are milliseconds.
|
||
| `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. |
|
||
| `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. |
|
||
| `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). |
|
||
+| `onSms` | refuse | `(sms) => Promise<void> \| void`. Takes every message a bound peer submits, on every session; `sms.session` says which: [Run an SMPP server](#run-an-smpp-server). |
|
||
| `systemId` | `''` | The SMSC identity returned in the bind response. |
|
||
| `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. |
|
||
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
|
||
@@ -298,11 +304,11 @@ refuse a non-ASCII sender of its own accord, which reaches you as a refusal such
|
||
|
||
| `encoding` | Alphabet | Characters per SMS | Per segment of a long message |
|
||
| --- | --- | --- | --- |
|
||
-| `ASCII` | GSM 03.38 7-bit | 160 | 153 |
|
||
+| `GSM` | GSM 03.38 7-bit | 160 | 153 |
|
||
| `LATIN1` | ISO 8859-1 | 140 | 134 |
|
||
| `UCS2` | UCS-2 | 70 | 67 |
|
||
|
||
-- Omitted: `ASCII` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named.
|
||
+- Omitted: `GSM` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named.
|
||
Any other name is refused.
|
||
- GSM extension characters (`{}[]\~^|€` and form feed) count as two, as does a character outside
|
||
the basic multilingual plane in `UCS2`.
|
||
@@ -357,9 +363,11 @@ holds for `session.send()`.
|
||
|
||
### Events
|
||
|
||
+A request you must answer is a handler option: `onSms`, and `onRequest` on a server. A fact you may
|
||
+watch is an event, and nothing waits on its listeners.
|
||
+
|
||
| Event | Fires when |
|
||
| --- | --- |
|
||
-| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. |
|
||
| `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). |
|
||
| `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). |
|
||
| `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. |
|
||
@@ -376,17 +384,14 @@ holds for `session.send()`.
|
||
|
||
**Shutdown.** `close()` and `unbind()` both:
|
||
|
||
-1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after
|
||
- `sendResp()`; await anything in between and it races the shutdown like any other send.
|
||
-2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application
|
||
- has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a
|
||
- message answered on arrival, when it is called at all), or when every listener that took the
|
||
- message has failed. Answering through `sendReturn()` instead leaves the wait running.
|
||
+1. Refuse further `sendSms()` and `send()`. `sendResp()` and `sendDlr()` still go out, because they
|
||
+ finish a message the shutdown is waiting on.
|
||
+2. Wait up to `shutdownTimeout` for every `onSms` handler still running, then for the requests
|
||
+ already sent.
|
||
3. Tear down what is left, resolving to an `err` that says what was lost.
|
||
|
||
-A message left unanswered for five minutes is no longer waited for.
|
||
-`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
|
||
-`responseTimeout` for its own response.
|
||
+A handler still running after five minutes is no longer waited for. `close({ signal })` cuts the wait
|
||
+short. `unbind()` takes no signal, and waits a further `responseTimeout` for its own response.
|
||
|
||
**Sends and the link.**
|
||
|
||
@@ -442,12 +447,15 @@ const { err, pduObj } = await session.send({
|
||
each is two messages.
|
||
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
|
||
[Server in depth](#server-in-depth).
|
||
-- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on,
|
||
- or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is
|
||
- refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery.
|
||
- No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are
|
||
- down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a
|
||
- later `sendResp()` still answers it. None of the three is an option.
|
||
+- **Messages being handled.** While 1000 `onSms` handlers are running on a session, or the messages
|
||
+ they hold weigh 64 MiB 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. The handler is not called. Reaching the bound logs one `warn`, and the first message
|
||
+ accepted once both are down to half one `info`. A handler running five minutes is dropped from the
|
||
+ count with a `warn`; its `sendResp()` still answers. None of the three is an option.
|
||
+- **A handler that fails.** One that throws or rejects reaches `sessionError`. Where it had not
|
||
+ answered, the message is refused with the same retry status, so the peer sends it again; where it
|
||
+ had, that answer stands. The same happens to every message where no `onSms` was given.
|
||
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and
|
||
the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages
|
||
and receipts included. A PDU filling both is read from `short_message`.
|
||
@@ -517,7 +525,7 @@ cannot, since a peer may number a message one part of one.
|
||
|
||
**Refusing a request** for a reason in the request rather than the message (a full queue, an unknown
|
||
recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on
|
||
-every request a bound peer sends, before reassembly and before the `sms` event:
|
||
+every request a bound peer sends, before reassembly and before `onSms`:
|
||
|
||
```javascript
|
||
import { isCommand, server } from '@larvit/smpp';
|
||
@@ -668,7 +676,7 @@ if (isCommand(pduObj, 'submit_sm')) {
|
||
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
|
||
| Time and ids | `smppDate`, `smppTime`, `uuidv7` |
|
||
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `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, hook, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `OnSms`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
|
||
|
||
## What changed per release
|
||
|
||
diff --git a/benchmarks/smsc-sink.ts b/benchmarks/smsc-sink.ts
|
||
index 324c11e..5779ba0 100644
|
||
--- a/benchmarks/smsc-sink.ts
|
||
+++ b/benchmarks/smsc-sink.ts
|
||
@@ -5,22 +5,20 @@ import { server } from '../src/server.ts';
|
||
* 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 });
|
||
+let answered = 0;
|
||
+const { err, server: smpp } = await server({
|
||
+ onSms: async sms => {
|
||
+ answered++;
|
||
+ await sms.sendResp();
|
||
+ },
|
||
+ 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`);
|
||
});
|
||
diff --git a/docs/decisions.md b/docs/decisions.md
|
||
index c245097..c34cb05 100644
|
||
--- a/docs/decisions.md
|
||
+++ b/docs/decisions.md
|
||
@@ -6,6 +6,24 @@ rule and an index of the titles below.
|
||
|
||
## The public surface
|
||
|
||
+- **A request the application must answer is a handler option; a fact it may watch is an event.**
|
||
+ Maintainer's call, 2026-09-29, from the comprehension panel's round two: every seat ranked the
|
||
+ `sms` event's hold hardest — released one turn after `sendResp()`, or when every listener had
|
||
+ rejected against a count taken at emit, or when no listener took it, or on a re-used sequence
|
||
+ number — and the internals could not be made simpler than the contract demanded. `onSms` on
|
||
+ `client()`, `server()` and `Session` takes each message, and the returned promise is the hold:
|
||
+ the message counts toward the bound and `close()` waits, until the handler settles. Goal 2 settles
|
||
+ the failure case: a handler that throws before answering has decided nothing, so the message is
|
||
+ refused with the retry status and the peer sends it again; one that threw after answering keeps
|
||
+ its answer, since a second response is goal 1's wire violation. No `onSms` at all takes the same
|
||
+ path, per message, so a receiver bound with nothing to receive into is visible rather than silent.
|
||
+ `dlr`, `messageDlr` and the life events stay events, since nothing waits on their listeners.
|
||
+ Rejected: keeping the event and waiting on the listener's own promise, which needs `listeners()`
|
||
+ re-declared and a count of them at emit — the contract that made the hold six exits. Rejected: a
|
||
+ handler that returns the answer for the library to write, which cannot express answering first and
|
||
+ working after, nor a receipt that must follow the answer inside the same hold. Rejected: a
|
||
+ settable `session.onSms`, a second spelling of the option.
|
||
+
|
||
- **`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.
|
||
@@ -17,17 +35,16 @@ rule and an index of the titles below.
|
||
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`,
|
||
+ 2026-08-27: `EventEmitter` types every listener as void-returning, so an
|
||
+ `session.on('dlr', async dlr => …)` 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.
|
||
+ set.delete(session))` returns a boolean. A listener's own promise cannot be waited on, since
|
||
+ reaching it needs `listeners()`, which cannot be re-declared the same way — Node types it
|
||
+ invariantly enough that widening `void` to `unknown` is `TS2416` — which is why a message goes to
|
||
+ a handler option rather than an event.
|
||
|
||
- **`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
|
||
@@ -225,8 +242,8 @@ rule and an index of the titles below.
|
||
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
|
||
+ other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on
|
||
+ `Sms`, 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
|
||
@@ -583,8 +600,8 @@ rule and an index of the titles below.
|
||
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
|
||
+- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one writes
|
||
+ nothing.** 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
|
||
@@ -596,7 +613,7 @@ rule and an index of the titles below.
|
||
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()`
|
||
+ an application that must refuse a PDU `onSms` 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:
|
||
@@ -613,7 +630,7 @@ rule and an index of the titles below.
|
||
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
|
||
+ in hand. The answer goes out before `onSms` is called either way, so a handler'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
|
||
@@ -653,36 +670,39 @@ rule and an index of the titles below.
|
||
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.
|
||
+- **The drain waits on the `onSms` handlers still running, and a handler's promise is what says it
|
||
+ is done with a message.** Maintainer's call, 2026-09-01, re-settled 2026-09-29 with the handler
|
||
+ option: 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.
|
||
+ The handler returning is the one signal, so a receipt sent inside it goes out before the drain
|
||
+ ends and `shutdownTimeout: 0` waits for it as it waits for a request, bounded by the five-minute
|
||
+ deadline past which a handler is no longer counted. Rejected: `sendResp()` as the signal, which
|
||
+ had to let a receipt sent one turn later past the drain and could not see a handler still working.
|
||
+ Rejected: counting every inbound request until `sendReturn()` answered it — an `onRequest` that
|
||
+ deliberately answers nothing would then cost a full `shutdownTimeout` on every close. Rejected: a
|
||
+ `responseTimeout` fallback for a `shutdownTimeout` of 0, a second bound where the deadline already
|
||
+ is one.
|
||
+
|
||
+- **A handler that fails before answering has the message refused for it; one that fails after has
|
||
+ its answer stand.** Goal 2: a handler that threw decided nothing, so the peer keeps the message and
|
||
+ retries on `ESME_RTHROTTLED` or `ESME_RX_T_APPN`, the same answer the bound gives; after an answer
|
||
+ nothing more is written, since a second response is goal 1's wire violation. `sendResp()` marks
|
||
+ the message answered before its write and refuses a second call, which is what makes the two
|
||
+ cases separable. Rejected: leaving the message unanswered, which is what the `sms` event did and
|
||
+ what stalls a peer that dispatches one request at a time.
|
||
+
|
||
+- **What the running handlers hold is capped on constants, and a message past the cap is
|
||
+ refused.** A bound the application cannot raise is the point: a handler that never returns 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 is still handling.
|
||
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:
|
||
+ (goal 2). Rejected: dropping the oldest to make room, which frees nothing while the handler 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.
|
||
+ application already knows, since it is the one not returning. A handler past its timeout is still
|
||
+ dropped from the count, so `close()` can report fewer than there were — accepted, because the
|
||
+ alternative is holding what nothing will finish.
|
||
|
||
- **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
|
||
diff --git a/interop-tests/cloudhopper.test.ts b/interop-tests/cloudhopper.test.ts
|
||
index dca5b5b..2ddda53 100644
|
||
--- a/interop-tests/cloudhopper.test.ts
|
||
+++ b/interop-tests/cloudhopper.test.ts
|
||
@@ -41,23 +41,20 @@ async function driver(path: string, params: Record<string, string> = {}): Promis
|
||
const manualTexts = new Set<string>();
|
||
const allSms: { session: Session; sms: Sms }[] = [];
|
||
|
||
-function attach(session: Session): void {
|
||
- session.on('sms', sms => {
|
||
- allSms.push({ session, sms });
|
||
+function onSms(sms: Sms): void {
|
||
+ allSms.push({ session: sms.session, sms });
|
||
|
||
- if (manualTexts.has(sms.message)) return;
|
||
+ if (manualTexts.has(sms.message)) return;
|
||
|
||
- // The slow server this phase's window scenarios need: every ordinary submit is held for
|
||
- // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window.
|
||
- void delay(SLOW_DELAY_MS).then(() => sms.sendResp());
|
||
- });
|
||
+ // The slow server this phase's window scenarios need: every ordinary submit is held for
|
||
+ // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window.
|
||
+ void delay(SLOW_DELAY_MS).then(() => sms.sendResp());
|
||
}
|
||
|
||
-const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT });
|
||
+const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, onSms, port: SMPP_PORT });
|
||
|
||
assert.equal(serverErr, undefined);
|
||
assert.ok(smpp);
|
||
-smpp.on('session', attach);
|
||
|
||
const key = readFileSync('/shared-certs/server.key');
|
||
const cert = readFileSync('/shared-certs/server.crt');
|
||
@@ -67,13 +64,13 @@ const cert = readFileSync('/shared-certs/server.crt');
|
||
const { err: tlsServerErr, server: tlsSmpp } = await server({
|
||
authenticate: () => true,
|
||
idleTimeout: 40_000,
|
||
+ onSms,
|
||
port: TLS_PORT,
|
||
tls: { cert, key, maxVersion: 'TLSv1.2' },
|
||
});
|
||
|
||
assert.equal(tlsServerErr, undefined);
|
||
assert.ok(tlsSmpp);
|
||
-tlsSmpp.on('session', attach);
|
||
|
||
after(async () => {
|
||
await smpp.close();
|
||
diff --git a/interop-tests/dumbclient.test.ts b/interop-tests/dumbclient.test.ts
|
||
index 81bc9ff..61b5d80 100644
|
||
--- a/interop-tests/dumbclient.test.ts
|
||
+++ b/interop-tests/dumbclient.test.ts
|
||
@@ -105,6 +105,23 @@ const { err, server: smpp } = await server({
|
||
authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }),
|
||
idleTimeout: 40_000,
|
||
log,
|
||
+ onSms: sms => {
|
||
+ const { session } = sms;
|
||
+ const name = scenarioOf(session);
|
||
+
|
||
+ sessionByScenario.set(name, session);
|
||
+
|
||
+ const s = statsFor(name);
|
||
+ const arrivalIndex = s.arrived;
|
||
+
|
||
+ s.arrived++;
|
||
+ if (s.ids.has(sms.smsId)) s.duplicateIds++;
|
||
+ else s.ids.add(sms.smsId);
|
||
+ s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered);
|
||
+
|
||
+ if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex);
|
||
+ else fastRespond(session, sms, arrivalIndex);
|
||
+ },
|
||
port: SMPP_PORT,
|
||
});
|
||
|
||
@@ -141,23 +158,6 @@ function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void {
|
||
smpp.on('session', session => {
|
||
// Attached now, not lazily in a test body - see the comment on ScenarioStats.closed.
|
||
session.on('close', () => { statsFor(scenarioOf(session)).closed = true; });
|
||
-
|
||
- session.on('sms', sms => {
|
||
- const name = scenarioOf(session);
|
||
-
|
||
- sessionByScenario.set(name, session);
|
||
-
|
||
- const s = statsFor(name);
|
||
- const arrivalIndex = s.arrived;
|
||
-
|
||
- s.arrived++;
|
||
- if (s.ids.has(sms.smsId)) s.duplicateIds++;
|
||
- else s.ids.add(sms.smsId);
|
||
- s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered);
|
||
-
|
||
- if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex);
|
||
- else fastRespond(session, sms, arrivalIndex);
|
||
- });
|
||
});
|
||
|
||
function memShape(): string {
|
||
@@ -206,7 +206,7 @@ after(async () => {
|
||
|
||
// S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a
|
||
// handler slowed enough to build a real backlog. window500 is the same shape with a window below
|
||
-// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a
|
||
+// maxHandledMessages (1000, defaults.ts), the bound past which a
|
||
// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent
|
||
// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled.
|
||
const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry';
|
||
@@ -236,7 +236,7 @@ describe('S9 - bounded window against a slowed handler', () => {
|
||
});
|
||
}
|
||
|
||
- test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => {
|
||
+ test('window 2000 pressed past maxHandledMessages (1000): the peer is throttled, window500 never is', () => {
|
||
assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000');
|
||
assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000);
|
||
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
|
||
diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts
|
||
index c6c9b92..f2fa41d 100644
|
||
--- a/interop-tests/jasmin.test.ts
|
||
+++ b/interop-tests/jasmin.test.ts
|
||
@@ -86,6 +86,20 @@ const { err: upstreamErr, server: upstream } = await server({
|
||
// was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past
|
||
// any per-test wait budget here - so this is generous specifically to never be the trigger.
|
||
idleTimeout: 300_000,
|
||
+ onSms: sms => {
|
||
+ const variant = (sms.session.userData as { variant?: UpstreamVariant } | undefined)?.variant;
|
||
+
|
||
+ if (variant) upstreamSms.push({ sms, variant });
|
||
+
|
||
+ void (async () => {
|
||
+ await sms.sendResp();
|
||
+
|
||
+ if (sms.dlr) {
|
||
+ await delay(150);
|
||
+ await sms.sendDlr('DELIVERED');
|
||
+ }
|
||
+ })();
|
||
+ },
|
||
port: UPSTREAM_PORT,
|
||
});
|
||
|
||
@@ -97,7 +111,7 @@ const upstreamServer = upstream;
|
||
upstreamServer.on('session', session => {
|
||
// `session` fires on raw connect, before authenticate() has run - session.userData is not set
|
||
// yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off
|
||
- // userData; userData is only read later, from 'sms', where authenticate() has long since run.
|
||
+ // userData; userData is only read later, in onSms, where authenticate() has long since run.
|
||
session.on('incomingPduObj', pduObj => {
|
||
if (!pduObj.cmdName.startsWith('bind_')) return;
|
||
|
||
@@ -105,21 +119,6 @@ upstreamServer.on('session', session => {
|
||
|
||
if (variant) upstreamSessions.set(variant, session);
|
||
});
|
||
-
|
||
- session.on('sms', sms => {
|
||
- const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant;
|
||
-
|
||
- if (variant) upstreamSms.push({ sms, variant });
|
||
-
|
||
- void (async () => {
|
||
- await sms.sendResp();
|
||
-
|
||
- if (sms.dlr) {
|
||
- await delay(150);
|
||
- await sms.sendDlr('DELIVERED');
|
||
- }
|
||
- })();
|
||
- });
|
||
});
|
||
|
||
async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise<Session> {
|
||
@@ -227,7 +226,7 @@ async function sendUdhMo(session: Session, opts: { from: string; message: string
|
||
const multipart = segments.length > 1;
|
||
|
||
for (const segment of segments) {
|
||
- const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'ASCII', multipart });
|
||
+ const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'GSM', multipart });
|
||
const sent = await session.send({ cmdName: 'deliver_sm', params });
|
||
|
||
assert.equal(sent.err, undefined);
|
||
@@ -247,7 +246,7 @@ async function sendMessagePayloadMo(session: Session, opts: { from: string; mess
|
||
},
|
||
tlvs: {
|
||
// The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM.
|
||
- message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer },
|
||
+ message_payload: { tagValue: encodeMessage(opts.message, 'GSM').buffer },
|
||
},
|
||
});
|
||
}
|
||
@@ -397,15 +396,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac
|
||
|
||
test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => {
|
||
const upstreamSession = await waitForUpstreamSession('main');
|
||
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||
+ const sms: Sms[] = [];
|
||
+ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
|
||
- const sms: Sms[] = [];
|
||
-
|
||
- session.on('sms', s => { sms.push(s); });
|
||
-
|
||
const text = `s7-long-${'p'.repeat(300)}`;
|
||
|
||
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||
@@ -418,15 +414,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac
|
||
|
||
test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => {
|
||
const upstreamSession = await waitForUpstreamSession('main');
|
||
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||
+ const sms: Sms[] = [];
|
||
+ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
|
||
- const sms: Sms[] = [];
|
||
-
|
||
- session.on('sms', s => { sms.push(s); });
|
||
-
|
||
const text = `一😀${'q'.repeat(60)}`;
|
||
|
||
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||
@@ -502,15 +495,12 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency
|
||
describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => {
|
||
test('SAR-segmented deliver_sm from the fake upstream', async () => {
|
||
const upstreamSession = await waitForUpstreamSession('main');
|
||
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||
+ const sms: Sms[] = [];
|
||
+ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
|
||
- const sms: Sms[] = [];
|
||
-
|
||
- session.on('sms', s => { sms.push(s); });
|
||
-
|
||
const text = `sar-mo-${'m'.repeat(300)}`;
|
||
|
||
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||
@@ -526,15 +516,12 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation
|
||
|
||
test('UDH-segmented deliver_sm from the fake upstream', async () => {
|
||
const upstreamSession = await waitForUpstreamSession('main');
|
||
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||
+ const sms: Sms[] = [];
|
||
+ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
|
||
- const sms: Sms[] = [];
|
||
-
|
||
- session.on('sms', s => { sms.push(s); });
|
||
-
|
||
const text = `udh-mo-${'n'.repeat(300)}`;
|
||
|
||
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||
@@ -551,15 +538,12 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation
|
||
describe('C8 (target 2) - message_payload with sm_length 0', () => {
|
||
test('a deliver_sm carrying message_payload instead of short_message', async () => {
|
||
const upstreamSession = await waitForUpstreamSession('main');
|
||
- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||
+ const sms: Sms[] = [];
|
||
+ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
|
||
- const sms: Sms[] = [];
|
||
-
|
||
- session.on('sms', s => { sms.push(s); });
|
||
-
|
||
const text = 'message-payload only, sm_length 0';
|
||
const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||
|
||
diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts
|
||
index db96513..21d2c45 100644
|
||
--- a/interop-tests/jsmpp.test.ts
|
||
+++ b/interop-tests/jsmpp.test.ts
|
||
@@ -46,6 +46,10 @@ const manualTexts = new Set<string>();
|
||
const { err: serverErr, server: smpp } = await server({
|
||
authenticate: () => true,
|
||
idleTimeout: 40_000,
|
||
+ onSms: sms => {
|
||
+ allSms.push({ session: sms.session, sms });
|
||
+ if (!manualTexts.has(sms.message)) void sms.sendResp();
|
||
+ },
|
||
port: SMPP_PORT,
|
||
});
|
||
|
||
@@ -58,10 +62,6 @@ smppServer.on('session', session => {
|
||
session.on('incomingPduObj', pduObj => {
|
||
if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params);
|
||
});
|
||
- session.on('sms', sms => {
|
||
- allSms.push({ session, sms });
|
||
- if (!manualTexts.has(sms.message)) void sms.sendResp();
|
||
- });
|
||
session.on('sessionError', err => { allSessionErrors.push({ err, session }); });
|
||
});
|
||
|
||
diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts
|
||
index 0d911d0..7324305 100644
|
||
--- a/interop-tests/kannel.test.ts
|
||
+++ b/interop-tests/kannel.test.ts
|
||
@@ -154,6 +154,15 @@ const { err: serverErr, server: smpp } = await server({
|
||
return variant ? { userData: { variant } } : false;
|
||
},
|
||
idleTimeout: 40_000,
|
||
+ onSms: sms => {
|
||
+ const variant = (sms.session.userData as { variant?: Variant } | undefined)?.variant;
|
||
+
|
||
+ if (variant) allSms.push({ sms, variant });
|
||
+
|
||
+ // max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a
|
||
+ // time - answer each as it lands, or the whole burst stalls behind the first message.
|
||
+ if (variant === 'maxp1') void sms.sendResp();
|
||
+ },
|
||
port: SMPP_PORT,
|
||
});
|
||
|
||
@@ -171,12 +180,6 @@ smppServer.on('session', session => {
|
||
if (variant) bindPdus.push({ params: pduObj.params, variant });
|
||
});
|
||
|
||
- session.on('sms', sms => {
|
||
- const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
|
||
-
|
||
- if (variant) allSms.push({ sms, variant });
|
||
- });
|
||
-
|
||
session.on('dlr', dlr => {
|
||
const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
|
||
|
||
@@ -517,10 +520,6 @@ describe('maxp1 variant - max-pending-submits 1', () => {
|
||
|
||
assert.ok(session);
|
||
|
||
- // max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a
|
||
- // time - answer each as it lands, or the whole burst stalls behind the first message.
|
||
- session.on('sms', sms => { void sms.sendResp(); });
|
||
-
|
||
const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`);
|
||
|
||
// Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever
|
||
diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts
|
||
index 0415ce9..c273f93 100644
|
||
--- a/interop-tests/php.test.ts
|
||
+++ b/interop-tests/php.test.ts
|
||
@@ -48,6 +48,17 @@ const { err: serverErr, server: smpp } = await server({
|
||
|
||
return true;
|
||
},
|
||
+ onSms: sms => {
|
||
+ const systemId = systemIdBySession.get(sms.session);
|
||
+
|
||
+ if (systemId) allSms.push({ sms, systemId });
|
||
+
|
||
+ // php-smpp's submit_sm() blocks synchronously reading the response on the same connection
|
||
+ // that sent it, so answering here (rather than after this event's own test observes the
|
||
+ // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one
|
||
+ // has no separate reader thread to poll afterwards.
|
||
+ void sms.sendResp();
|
||
+ },
|
||
port: SMPP_PORT,
|
||
});
|
||
|
||
@@ -62,18 +73,6 @@ smppServer.on('session', session => {
|
||
|
||
systemIdBySession.set(session, paramText(pduObj.params.system_id));
|
||
});
|
||
-
|
||
- session.on('sms', sms => {
|
||
- const systemId = systemIdBySession.get(session);
|
||
-
|
||
- if (systemId) allSms.push({ sms, systemId });
|
||
-
|
||
- // php-smpp's submit_sm() blocks synchronously reading the response on the same connection
|
||
- // that sent it, so answering here (rather than after this event's own test observes the
|
||
- // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one
|
||
- // has no separate reader thread to poll afterwards.
|
||
- void sms.sendResp();
|
||
- });
|
||
});
|
||
|
||
after(async () => {
|
||
diff --git a/interop-tests/python.test.ts b/interop-tests/python.test.ts
|
||
index 84f1c66..97631c0 100644
|
||
--- a/interop-tests/python.test.ts
|
||
+++ b/interop-tests/python.test.ts
|
||
@@ -68,6 +68,11 @@ const { err: serverErr, server: smpp } = await server({
|
||
|
||
return true;
|
||
},
|
||
+ onSms: sms => {
|
||
+ const systemId = systemIdBySession.get(sms.session);
|
||
+
|
||
+ if (systemId) allSms.push({ sms, systemId });
|
||
+ },
|
||
port: SMPP_PORT,
|
||
});
|
||
|
||
@@ -82,12 +87,6 @@ smppServer.on('session', session => {
|
||
|
||
systemIdBySession.set(session, paramText(pduObj.params.system_id));
|
||
});
|
||
-
|
||
- session.on('sms', sms => {
|
||
- const systemId = systemIdBySession.get(session);
|
||
-
|
||
- if (systemId) allSms.push({ sms, systemId });
|
||
- });
|
||
});
|
||
|
||
after(async () => {
|
||
@@ -149,7 +148,7 @@ async function waitForAck(name: string, sequence: number, budget = 8000): Promis
|
||
}
|
||
|
||
async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise<void> {
|
||
- const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII';
|
||
+ const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'GSM';
|
||
const buf = encodings[encName].encode(text);
|
||
const sent = await session.send({
|
||
cmdName: 'deliver_sm',
|
||
diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts
|
||
index 8a08817..b5e775d 100644
|
||
--- a/interop-tests/smppsim.test.ts
|
||
+++ b/interop-tests/smppsim.test.ts
|
||
@@ -72,12 +72,11 @@ function collectDlrs(session: Session): Received[] {
|
||
return received;
|
||
}
|
||
|
||
-function collectSms(session: Session): Sms[] {
|
||
+/** Every message a client's handler was handed; `onSms` goes to the bind. */
|
||
+function smsInbox(): { collected: Sms[]; onSms: (sms: Sms) => void } {
|
||
const collected: Sms[] = [];
|
||
|
||
- session.on('sms', sms => { collected.push(sms); });
|
||
-
|
||
- return collected;
|
||
+ return { collected, onSms: sms => { collected.push(sms); } };
|
||
}
|
||
|
||
const DLR_RETRY_BUDGET_MS = 3000;
|
||
@@ -202,14 +201,15 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
|
||
|
||
for (const testCase of cases) {
|
||
test(testCase.label, async t => {
|
||
- const { err, session } = await bind(PEER_HOST);
|
||
+ const inbox = smsInbox();
|
||
+ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
closeAfter(t, session);
|
||
|
||
const dlrs = collectDlrs(session);
|
||
- const sms = collectSms(session);
|
||
+ const sms = inbox.collected;
|
||
|
||
const { reassembled, smsIds } = await sendUntilComplete(
|
||
session,
|
||
@@ -588,13 +588,14 @@ describe('smppsim - C15 bind version negotiation', () => {
|
||
|
||
describe('smppsim - C17 encodings round trip over loopback', () => {
|
||
test('Latin-1 (å ä ö)', async t => {
|
||
- const { err, session } = await bind(PEER_HOST);
|
||
+ const inbox = smsInbox();
|
||
+ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
closeAfter(t, session);
|
||
|
||
- const sms = collectSms(session);
|
||
+ const sms = inbox.collected;
|
||
|
||
await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO });
|
||
|
||
@@ -605,13 +606,14 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
|
||
});
|
||
|
||
test('UCS-2', async t => {
|
||
- const { err, session } = await bind(PEER_HOST);
|
||
+ const inbox = smsInbox();
|
||
+ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
closeAfter(t, session);
|
||
|
||
- const sms = collectSms(session);
|
||
+ const sms = inbox.collected;
|
||
|
||
await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO });
|
||
|
||
@@ -622,13 +624,14 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
|
||
});
|
||
|
||
test('flash (data_coding records the message-class group)', async t => {
|
||
- const { err, session } = await bind(PEER_HOST);
|
||
+ const inbox = smsInbox();
|
||
+ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
closeAfter(t, session);
|
||
|
||
- const sms = collectSms(session);
|
||
+ const sms = inbox.collected;
|
||
|
||
await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO });
|
||
|
||
@@ -640,13 +643,14 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
|
||
});
|
||
|
||
test('a raw submit_sm with data_coding 0xF0 is read as flash', async t => {
|
||
- const { err, session } = await bind(PEER_HOST);
|
||
+ const inbox = smsInbox();
|
||
+ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
closeAfter(t, session);
|
||
|
||
- const sms = collectSms(session);
|
||
+ const sms = inbox.collected;
|
||
const body = 'message class test';
|
||
|
||
const sent = await session.send({
|
||
@@ -668,13 +672,14 @@ describe('smppsim - C17 encodings round trip over loopback', () => {
|
||
});
|
||
|
||
test('a raw submit_sm with 8-bit binary and a UDH (esm_class 0x40)', async t => {
|
||
- const { err, session } = await bind(PEER_HOST);
|
||
+ const inbox = smsInbox();
|
||
+ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(session);
|
||
closeAfter(t, session);
|
||
|
||
- const sms = collectSms(session);
|
||
+ const sms = inbox.collected;
|
||
// A UDH carrying no recognised concatenation IE (0x00/0x08): one element in GSM 03.40's
|
||
// reserved-for-future-use range (0x70), so Wireshark's gsm_sms_ud dissector - which
|
||
// validates the *typed* IEs' own lengths (0x01 "Special SMS Message Indication" must be
|
||
diff --git a/interop-tests/smscsim.test.ts b/interop-tests/smscsim.test.ts
|
||
index ba23042..0c1785c 100644
|
||
--- a/interop-tests/smscsim.test.ts
|
||
+++ b/interop-tests/smscsim.test.ts
|
||
@@ -163,9 +163,11 @@ describe('smscsim - multipart segments', () => {
|
||
|
||
describe('smscsim - MO injection through the web UI', () => {
|
||
test('a message posted to the web page arrives as an sms event', async t => {
|
||
+ const incoming: Sms[] = [];
|
||
const { err, session } = await client({
|
||
bindType: 'transceiver',
|
||
host: PEER_HOST,
|
||
+ onSms: sms => { incoming.push(sms); },
|
||
port: PEER_PORT,
|
||
username: 'mo-inject',
|
||
});
|
||
@@ -174,10 +176,6 @@ describe('smscsim - MO injection through the web UI', () => {
|
||
assert.ok(session);
|
||
closeAfter(t, session);
|
||
|
||
- const incoming: Sms[] = [];
|
||
-
|
||
- session.on('sms', sms => { incoming.push(sms); });
|
||
-
|
||
const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, {
|
||
body: new URLSearchParams({
|
||
message: 'hello from the web UI',
|
||
diff --git a/src/bind-direction.ts b/src/bind-direction.ts
|
||
new file mode 100644
|
||
index 0000000..b810cf6
|
||
--- /dev/null
|
||
+++ b/src/bind-direction.ts
|
||
@@ -0,0 +1,71 @@
|
||
+import type { Result } from './result.ts';
|
||
+import { namedValue } from './error-from.ts';
|
||
+
|
||
+export type BindType = 'receiver' | 'transceiver' | 'transmitter';
|
||
+
|
||
+const bindTypes: readonly BindType[] = ['receiver', 'transceiver', 'transmitter'];
|
||
+
|
||
+export const bindCommands: readonly string[] = bindTypes.map(bindType => `bind_${bindType}`);
|
||
+
|
||
+export function bindTypeFromCommand(cmdName: string): BindType | undefined {
|
||
+ return bindTypes.find(bindType => `bind_${bindType}` === cmdName);
|
||
+}
|
||
+
|
||
+function isBindType(value: unknown): value is BindType {
|
||
+ return bindTypes.some(bindType => bindType === value);
|
||
+}
|
||
+
|
||
+/** Which end of the link a session is. Only `server()` is the SMSC; everything else is the ESME. */
|
||
+export type LinkEnd = 'esme' | 'smsc';
|
||
+
|
||
+/**
|
||
+ * 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 quoted(value: unknown): string {
|
||
+ return typeof value === 'string' ? JSON.stringify(value) : namedValue(value);
|
||
+}
|
||
+
|
||
+/** 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 } };
|
||
+}
|
||
diff --git a/src/client.ts b/src/client.ts
|
||
index 6b7299c..3ddd716 100644
|
||
--- a/src/client.ts
|
||
+++ b/src/client.ts
|
||
@@ -1,6 +1,7 @@
|
||
import type { ConnectionOptions } from 'node:tls';
|
||
import type { Result, VoidResult } from './result.ts';
|
||
-import type { BindType, ReconnectOptions } from './session-options.ts';
|
||
+import type { BindType } from './bind-direction.ts';
|
||
+import type { OnSms, ReconnectOptions } from './session-options.ts';
|
||
import type { SmppLog } from './log.ts';
|
||
import type { SmsIdFormat } from './sms-id.ts';
|
||
import type { Socket } from 'node:net';
|
||
@@ -11,7 +12,7 @@ import { Session } from './session.ts';
|
||
import { checkSessionOptions } from './session-options.ts';
|
||
import { connect as netConnect } from 'node:net';
|
||
import { connect as tlsConnect } from 'node:tls';
|
||
-import { defaultInterfaceVersion } from './defs/constants.ts';
|
||
+import { defaults } from './defaults.ts';
|
||
import { guardedLog } from './log.ts';
|
||
|
||
/** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */
|
||
@@ -29,6 +30,7 @@ export type ClientOptions = {
|
||
interfaceVersion?: number;
|
||
log?: SmppLog;
|
||
maxOutstanding?: number;
|
||
+ onSms?: OnSms;
|
||
password?: string;
|
||
port?: number;
|
||
reconnect?: ReconnectTuning | false;
|
||
@@ -41,19 +43,6 @@ export type ClientOptions = {
|
||
username?: string;
|
||
};
|
||
|
||
-const defaults = {
|
||
- bindType: 'transceiver',
|
||
- connectTimeout: 10_000,
|
||
- enquireLinkInterval: 20_000,
|
||
- host: 'localhost',
|
||
- /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
|
||
- idleTimeoutFactor: 2,
|
||
- interfaceVersion: defaultInterfaceVersion,
|
||
- password: 'pass',
|
||
- port: 2775,
|
||
- username: 'user',
|
||
-} as const;
|
||
-
|
||
function armConnectTimeout(
|
||
sock: Socket,
|
||
connectTimeout: number | false,
|
||
@@ -201,9 +190,11 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
|
||
|
||
return new Session({
|
||
enquireLinkInterval,
|
||
- idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor,
|
||
+ // Two silent probes: one lost enquire_link must not drop a live link.
|
||
+ idleTimeout: options.idleTimeout ?? enquireLinkInterval * 2,
|
||
log,
|
||
maxOutstanding: options.maxOutstanding,
|
||
+ onSms: options.onSms,
|
||
reconnect: reconnectFor(options, log),
|
||
responseTimeout: options.responseTimeout,
|
||
shutdownTimeout: options.shutdownTimeout,
|
||
diff --git a/src/defaults.ts b/src/defaults.ts
|
||
new file mode 100644
|
||
index 0000000..dcaa84b
|
||
--- /dev/null
|
||
+++ b/src/defaults.ts
|
||
@@ -0,0 +1,31 @@
|
||
+import { defaultInterfaceVersion } from './defs/constants.ts';
|
||
+
|
||
+/** Every default the session layer runs on. README's option tables quote these. */
|
||
+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. */
|
||
+ dlrMergeTimeout: 86_400_000,
|
||
+ enquireLinkInterval: 20_000,
|
||
+ /** A handler still running this long is no longer counted or waited for. */
|
||
+ handlerTimeout: 300_000,
|
||
+ host: 'localhost',
|
||
+ /** Two silent enquire_link intervals, so one lost probe does not drop a live link. */
|
||
+ idleTimeout: 40_000,
|
||
+ interfaceVersion: defaultInterfaceVersion,
|
||
+ maxDlrMerges: 1000,
|
||
+ maxHandledMessages: 1000,
|
||
+ maxHandledOctets: 64 * 1024 * 1024,
|
||
+ maxOutstanding: 10,
|
||
+ maxReassembly: 1000,
|
||
+ maxReassemblyOctets: 64 * 1024 * 1024,
|
||
+ password: 'pass',
|
||
+ port: 2775,
|
||
+ reassemblyTimeout: 300_000,
|
||
+ reconnectMaxDelay: 30_000,
|
||
+ reconnectMinDelay: 1000,
|
||
+ responseTimeout: 30_000,
|
||
+ shutdownTimeout: 5000,
|
||
+ systemId: '',
|
||
+ username: 'user',
|
||
+} as const;
|
||
diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts
|
||
index c454b02..a1c6216 100644
|
||
--- a/src/defs/encodings.ts
|
||
+++ b/src/defs/encodings.ts
|
||
@@ -1,4 +1,4 @@
|
||
-export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2';
|
||
+export type EncodingName = 'GSM' | 'LATIN1' | 'UCS2';
|
||
|
||
export type Encoding = {
|
||
decode: (buffer: Uint8Array) => string;
|
||
@@ -103,7 +103,7 @@ const ucs2: Encoding = {
|
||
};
|
||
|
||
export const encodings: Record<EncodingName, Encoding> = {
|
||
- ASCII: ascii,
|
||
+ GSM: ascii,
|
||
LATIN1: latin1,
|
||
UCS2: ucs2,
|
||
};
|
||
@@ -115,7 +115,7 @@ export function isEncodingName(value: unknown): value is EncodingName {
|
||
}
|
||
|
||
export function detect(value: string): EncodingName {
|
||
- if (encodings.ASCII.match(value)) return 'ASCII';
|
||
+ if (encodings.GSM.match(value)) return 'GSM';
|
||
if (encodings.LATIN1.match(value)) return 'LATIN1';
|
||
|
||
return 'UCS2';
|
||
@@ -163,21 +163,21 @@ function messageClassEncoding(dataCoding: number): EncodingName | undefined {
|
||
if (messageClassOf(dataCoding) === undefined) return undefined;
|
||
|
||
if ((dataCoding & 0xF0) === 0xF0) {
|
||
- return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII';
|
||
+ return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'GSM';
|
||
}
|
||
|
||
const alphabet = (dataCoding >> 2) & 0x03;
|
||
|
||
if (alphabet === 0x01) return 'LATIN1';
|
||
|
||
- return alphabet === 0x02 ? 'UCS2' : 'ASCII';
|
||
+ return alphabet === 0x02 ? 'UCS2' : 'GSM';
|
||
}
|
||
|
||
/**
|
||
* SMPP data_coding is a flat table for 0x00-0x0E, and the message class ranges are how a flash UCS2
|
||
* message arrives as 0x18. The 8-bit binary codings resolve to LATIN1, the one codec here that maps
|
||
* every octet to a code point and back unchanged, so a binary payload survives; alphabets with no
|
||
- * codec fall back to ASCII.
|
||
+ * codec fall back to GSM.
|
||
*/
|
||
export function encodingByDataCoding(dataCoding: number): EncodingName {
|
||
const messageClass = messageClassEncoding(dataCoding);
|
||
@@ -186,7 +186,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName {
|
||
if (dataCoding === 0x08) return 'UCS2';
|
||
|
||
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
|
||
- return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
|
||
+ return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'GSM';
|
||
}
|
||
|
||
/**
|
||
@@ -194,7 +194,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName {
|
||
* takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5.
|
||
*/
|
||
export const dataCodingByEncoding: Readonly<Record<EncodingName, number>> = {
|
||
- ASCII: 0x00,
|
||
+ GSM: 0x00,
|
||
LATIN1: 0x03,
|
||
UCS2: 0x08,
|
||
};
|
||
diff --git a/src/dlr-merger.ts b/src/dlr-merger.ts
|
||
index 0c20cae..b61529a 100644
|
||
--- a/src/dlr-merger.ts
|
||
+++ b/src/dlr-merger.ts
|
||
@@ -126,7 +126,7 @@ export class DlrMerger {
|
||
|
||
if (group.parts.size < group.expected.size) return undefined;
|
||
|
||
- this.close(base);
|
||
+ this.spend(base);
|
||
|
||
const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one);
|
||
const worst = segments.reduce((carry, one) => (severity[one.statusMsg] > severity[carry.statusMsg] ? one : carry));
|
||
@@ -142,7 +142,7 @@ export class DlrMerger {
|
||
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
|
||
sweep(): void {
|
||
for (const [base, group] of this.groups.takeExpired()) {
|
||
- this.close(base);
|
||
+ this.spend(base);
|
||
this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size });
|
||
}
|
||
}
|
||
@@ -151,7 +151,7 @@ export class DlrMerger {
|
||
this.spent.takeExpired();
|
||
|
||
if (this.groups.get(base) !== undefined || this.spent.get(base) === true) {
|
||
- this.close(base);
|
||
+ this.spend(base);
|
||
this.log.info('dlrMerger - message id handed out again, leaving its receipts unmerged', { base });
|
||
|
||
return;
|
||
@@ -162,7 +162,8 @@ export class DlrMerger {
|
||
this.groups.set(base, { expected, parts: new Map() });
|
||
}
|
||
|
||
- private close(base: string): void {
|
||
+ /** The base is finished with, merged or not, and never opens again. */
|
||
+ private spend(base: string): void {
|
||
this.groups.delete(base);
|
||
this.spent.delete(base);
|
||
|
||
@@ -178,7 +179,7 @@ export class DlrMerger {
|
||
|
||
const [base] = oldest;
|
||
|
||
- this.close(base);
|
||
+ this.spend(base);
|
||
this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max });
|
||
}
|
||
}
|
||
diff --git a/src/handled-messages.ts b/src/handled-messages.ts
|
||
new file mode 100644
|
||
index 0000000..47776b3
|
||
--- /dev/null
|
||
+++ b/src/handled-messages.ts
|
||
@@ -0,0 +1,157 @@
|
||
+import type { ErrorName } from './defs/errors.ts';
|
||
+import type { OnSms } from './session-options.ts';
|
||
+import type { Sms, SmsHandlers, SmsInput } from './sms.ts';
|
||
+import type { SmppLog } from './log.ts';
|
||
+import { ExpiringGroups } from './expiring-groups.ts';
|
||
+import { IdleWaiters } from './idle-waiters.ts';
|
||
+import { createSms } from './sms.ts';
|
||
+import { errorFrom } from './error-from.ts';
|
||
+import { retainedOctets } from './retained-pdu.ts';
|
||
+
|
||
+export type HandledMessagesOptions = {
|
||
+ log: SmppLog;
|
||
+ max: number;
|
||
+ maxOctets: number;
|
||
+ /** Injected so expiry can be exercised without a wall clock. */
|
||
+ now?: (() => number) | undefined;
|
||
+ onSms: OnSms | undefined;
|
||
+ /** Where a handler's failure is reported. */
|
||
+ report: (err: Error) => void;
|
||
+ timeout: number;
|
||
+};
|
||
+
|
||
+/** What a message needs from the session, less the notice this class takes for itself. */
|
||
+export type SmsRoute = Omit<SmsHandlers, 'answered'>;
|
||
+
|
||
+type Handled = { answered: boolean; sms: Sms };
|
||
+
|
||
+/**
|
||
+ * The messages whose handler is running. Each counts toward the bound and holds a shutdown until
|
||
+ * the handler settles, its deadline passes, or the link goes. A message a handler failed on, or
|
||
+ * that no handler takes, is answered here where it was not, asking the peer to retry.
|
||
+ */
|
||
+export class HandledMessages {
|
||
+ private readonly idleWaiters = new IdleWaiters();
|
||
+ private readonly log: SmppLog;
|
||
+ private readonly max: number;
|
||
+ private readonly maxOctets: number;
|
||
+ private readonly onSms: OnSms | undefined;
|
||
+ private readonly report: (err: Error) => void;
|
||
+ private readonly running: ExpiringGroups<Handled>;
|
||
+ private atBound = false;
|
||
+ private keys = 0;
|
||
+
|
||
+ constructor(options: HandledMessagesOptions) {
|
||
+ this.log = options.log;
|
||
+ this.max = options.max;
|
||
+ this.maxOctets = options.maxOctets;
|
||
+ this.onSms = options.onSms;
|
||
+ this.report = options.report;
|
||
+ this.running = new ExpiringGroups({
|
||
+ max: options.max,
|
||
+ now: options.now,
|
||
+ onSweep: () => { this.sweep(); },
|
||
+ timeout: options.timeout,
|
||
+ });
|
||
+ }
|
||
+
|
||
+ get octets(): number {
|
||
+ return this.running.weight;
|
||
+ }
|
||
+
|
||
+ get size(): number {
|
||
+ return this.running.size;
|
||
+ }
|
||
+
|
||
+ /** Whether a message arriving now is refused: at the bound, and until the store is half empty again. */
|
||
+ refuses(): boolean {
|
||
+ this.sweep();
|
||
+
|
||
+ if (this.running.full || this.running.weight >= this.maxOctets) {
|
||
+ if (!this.atBound) {
|
||
+ this.atBound = true;
|
||
+ this.log.warn('handledMessages - messages at their bound, refusing new ones until handlers return', {
|
||
+ messages: this.size,
|
||
+ octets: this.octets,
|
||
+ });
|
||
+ }
|
||
+
|
||
+ return true;
|
||
+ }
|
||
+
|
||
+ // Half, so a peer keeping its window full does not flip this on every answer.
|
||
+ if (this.atBound && this.size <= this.max / 2 && this.octets <= this.maxOctets / 2) {
|
||
+ this.atBound = false;
|
||
+ this.log.info('handledMessages - messages down to half their bound, accepting again', { messages: this.size });
|
||
+ }
|
||
+
|
||
+ return false;
|
||
+ }
|
||
+
|
||
+ /** Hands the message to the handler. `retryStatus` answers it where the handler fails first. */
|
||
+ offer(input: SmsInput, route: SmsRoute, retryStatus: ErrorName): Sms {
|
||
+ const key = String(this.keys++);
|
||
+ const handled: Handled = { answered: false, sms: createSms(input, { ...route, answered: () => { handled.answered = true; } }) };
|
||
+
|
||
+ this.sweep();
|
||
+ this.running.set(key, handled);
|
||
+ this.running.weigh(key, input.pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0));
|
||
+ void this.run(key, handled, retryStatus);
|
||
+
|
||
+ return handled.sms;
|
||
+ }
|
||
+
|
||
+ /** Drops every message: their segments went with the link, so no answer of ours correlates now. */
|
||
+ clear(): void {
|
||
+ this.running.takeAll();
|
||
+ this.idleWaiters.settle();
|
||
+ }
|
||
+
|
||
+ /** Resolves 0 once every handler has returned, or with how many have not. */
|
||
+ idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
|
||
+ return this.idleWaiters.wait(() => this.running.size, timeout, signal);
|
||
+ }
|
||
+
|
||
+ /** Drops every message past its deadline. Runs before each offer and on its own timer. */
|
||
+ sweep(): void {
|
||
+ const expired = this.running.takeExpired();
|
||
+
|
||
+ if (expired.length === 0) return;
|
||
+
|
||
+ this.log.warn('handledMessages - handlers still running past their deadline', { messages: expired.length });
|
||
+ this.settle();
|
||
+ }
|
||
+
|
||
+ private async run(key: string, handled: Handled, retryStatus: ErrorName): Promise<void> {
|
||
+ const failure = await this.handle(handled.sms);
|
||
+
|
||
+ if (failure) {
|
||
+ this.log.error('handledMessages - a handler failed', { message: failure.message });
|
||
+ this.report(failure);
|
||
+
|
||
+ // A handler that failed before answering has decided nothing, so the peer keeps the message.
|
||
+ if (!handled.answered) await handled.sms.sendResp({ status: retryStatus });
|
||
+ }
|
||
+
|
||
+ if (this.running.get(key) !== handled) return;
|
||
+
|
||
+ this.running.delete(key);
|
||
+ this.settle();
|
||
+ }
|
||
+
|
||
+ private async handle(sms: Sms): Promise<Error | undefined> {
|
||
+ if (!this.onSms) return new Error('No onSms handler takes inbound messages');
|
||
+
|
||
+ try {
|
||
+ await this.onSms(sms);
|
||
+
|
||
+ return undefined;
|
||
+ } catch (thrown: unknown) {
|
||
+ return errorFrom(thrown);
|
||
+ }
|
||
+ }
|
||
+
|
||
+ private settle(): void {
|
||
+ if (this.running.size === 0) this.idleWaiters.settle();
|
||
+ }
|
||
+}
|
||
diff --git a/src/held-messages.ts b/src/held-messages.ts
|
||
deleted file mode 100644
|
||
index b9e740e..0000000
|
||
--- a/src/held-messages.ts
|
||
+++ /dev/null
|
||
@@ -1,201 +0,0 @@
|
||
-import type { LinkLife } from './link-life.ts';
|
||
-import type { PduObject, PduObjectInput } from './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 './expiring-groups.ts';
|
||
-import { IdleWaiters } from './idle-waiters.ts';
|
||
-import { createSms } from './sms.ts';
|
||
-import { retainedOctets } from './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();
|
||
- }
|
||
-}
|
||
diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts
|
||
index 51aeec7..35d37f1 100644
|
||
--- a/src/incoming-requests.ts
|
||
+++ b/src/incoming-requests.ts
|
||
@@ -1,26 +1,27 @@
|
||
import type { Concat } from './concat.ts';
|
||
import type { DlrMerger } from './dlr-merger.ts';
|
||
import type { ErrorName } from './defs/errors.ts';
|
||
-import type { HeldMessagesOptions } from './held-messages.ts';
|
||
import type { LinkLife } from './link-life.ts';
|
||
import type { LostGroup, Refusal } from './reassembly.ts';
|
||
-import type { OnRequest } from './session-options.ts';
|
||
+import type { OnRequest, OnSms } from './session-options.ts';
|
||
import type { PduObject } from './pdu.ts';
|
||
-import type { VoidResult } from './result.ts';
|
||
import type { Session } from './session.ts';
|
||
import type { SmppLog } from './log.ts';
|
||
+import type { SmsHandlers } from './sms.ts';
|
||
import type { SmsIdFormat } from './sms-id.ts';
|
||
-import { HeldMessages } from './held-messages.ts';
|
||
+import type { VoidResult } from './result.ts';
|
||
+import { HandledMessages } from './handled-messages.ts';
|
||
import { Reassembler } from './reassembly.ts';
|
||
-import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
||
+import { bindCommands, standsInFor } from './bind-direction.ts';
|
||
import { concatOf } from './concat.ts';
|
||
+import { defaults } from './defaults.ts';
|
||
import { detach } from './retained-pdu.ts';
|
||
import { dlrFromPdu } from './dlr.ts';
|
||
import { respIdParams, segmentId } from './sms-id.ts';
|
||
import { respNameFor } from './defs/commands.ts';
|
||
|
||
/** Asks the peer to keep the message and retry. */
|
||
-function throttledStatus(carriedAs: string): ErrorName {
|
||
+function retryStatus(carriedAs: string): ErrorName {
|
||
return carriedAs === 'submit_sm' ? 'ESME_RTHROTTLED' : 'ESME_RX_T_APPN';
|
||
}
|
||
|
||
@@ -34,7 +35,7 @@ export function refusedSegmentStatus(
|
||
return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS';
|
||
}
|
||
|
||
- return throttledStatus(carriedAs);
|
||
+ return retryStatus(carriedAs);
|
||
}
|
||
|
||
const lostReasons: Record<LostGroup['reason'], string> = {
|
||
@@ -44,14 +45,17 @@ const lostReasons: Record<LostGroup['reason'], string> = {
|
||
};
|
||
|
||
export type IncomingRequestsOptions = {
|
||
+ answer: SmsHandlers['answer'];
|
||
dlrMerger: DlrMerger;
|
||
link: LinkLife;
|
||
log: SmppLog;
|
||
maxOctets?: number | undefined;
|
||
maxReassembly?: number | undefined;
|
||
onRequest?: OnRequest | undefined;
|
||
+ onSms?: OnSms | undefined;
|
||
reassemblyTimeout?: number | undefined;
|
||
- sendPastDrain: HeldMessagesOptions['sendPastDrain'];
|
||
+ /** Puts a receipt on the wire, on a closing session too. */
|
||
+ sendReceipt: SmsHandlers['send'];
|
||
session: Session;
|
||
smsIdFormat?: SmsIdFormat | undefined;
|
||
systemId?: string | undefined;
|
||
@@ -59,27 +63,28 @@ export type IncomingRequestsOptions = {
|
||
|
||
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
|
||
export class IncomingRequests {
|
||
+ private readonly answer: SmsHandlers['answer'];
|
||
private readonly dlrMerger: DlrMerger;
|
||
- private readonly held: HeldMessages;
|
||
+ private readonly handled: HandledMessages;
|
||
private readonly link: LinkLife;
|
||
private readonly log: SmppLog;
|
||
private readonly onRequest: OnRequest | undefined;
|
||
private readonly reassembler: Reassembler;
|
||
+ private readonly sendReceipt: SmsHandlers['send'];
|
||
private readonly session: Session;
|
||
private readonly smsIdFormat: SmsIdFormat;
|
||
private readonly systemId: string;
|
||
- private refusing = false;
|
||
|
||
constructor(options: IncomingRequestsOptions) {
|
||
+ this.answer = options.answer;
|
||
this.dlrMerger = options.dlrMerger;
|
||
- this.held = new HeldMessages({
|
||
- link: options.link,
|
||
+ this.handled = new HandledMessages({
|
||
log: options.log,
|
||
- max: defaults.maxHeldMessages,
|
||
- maxOctets: defaults.maxHeldOctets,
|
||
- sendPastDrain: options.sendPastDrain,
|
||
- session: options.session,
|
||
- timeout: defaults.heldMessageTimeout,
|
||
+ max: defaults.maxHandledMessages,
|
||
+ maxOctets: defaults.maxHandledOctets,
|
||
+ onSms: options.onSms,
|
||
+ report: err => { options.session.emit('sessionError', err); },
|
||
+ timeout: defaults.handlerTimeout,
|
||
});
|
||
this.link = options.link;
|
||
this.log = options.log;
|
||
@@ -91,6 +96,7 @@ export class IncomingRequests {
|
||
onLost: lost => { this.reportLost(lost); },
|
||
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
|
||
});
|
||
+ this.sendReceipt = options.sendReceipt;
|
||
this.session = options.session;
|
||
this.smsIdFormat = options.smsIdFormat ?? {};
|
||
this.systemId = options.systemId ?? defaults.systemId;
|
||
@@ -115,7 +121,7 @@ export class IncomingRequests {
|
||
bindType: this.session.boundAs ?? '',
|
||
cmdName: pduObj.cmdName,
|
||
});
|
||
- await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
||
+ this.answer(pduObj, 'ESME_RINVBNDSTS', {});
|
||
|
||
return;
|
||
}
|
||
@@ -128,52 +134,46 @@ export class IncomingRequests {
|
||
case 'data_sm':
|
||
case 'deliver_sm':
|
||
// A data_sm at the SMSC end is a submission, and a submission is never a report.
|
||
- await (this.carriedAs(pduObj) === 'submit_sm'
|
||
- ? this.onMessage(pduObj)
|
||
- : this.onDelivery(pduObj));
|
||
+ if (this.carriedAs(pduObj) === 'submit_sm') this.onMessage(pduObj);
|
||
+ else this.onDelivery(pduObj);
|
||
break;
|
||
case 'enquire_link':
|
||
- await this.session.sendReturn(pduObj);
|
||
+ this.answer(pduObj, 'ESME_ROK', {});
|
||
break;
|
||
case 'submit_sm':
|
||
- await this.onMessage(pduObj);
|
||
+ this.onMessage(pduObj);
|
||
break;
|
||
case 'unbind':
|
||
- await this.session.sendReturn(pduObj);
|
||
+ this.answer(pduObj, 'ESME_ROK', {});
|
||
// A peer that has said it is finished will not answer what we still have outstanding.
|
||
await this.session.close({ signal: AbortSignal.abort() });
|
||
break;
|
||
default:
|
||
- await this.unhandled(pduObj);
|
||
+ this.unhandled(pduObj);
|
||
}
|
||
}
|
||
|
||
- /** 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 stops waiting on every handler. */
|
||
clear(): void {
|
||
- this.refusing = false;
|
||
- this.held.clear();
|
||
+ this.handled.clear();
|
||
this.reassembler.clear();
|
||
}
|
||
|
||
- listenerRejected(sms: unknown): void {
|
||
- this.held.listenerRejected(sms);
|
||
- }
|
||
-
|
||
- /** Waits out the messages the application still holds, and says how many it never answered. */
|
||
+ /** Waits out the handlers still running, and says how many never returned. */
|
||
async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
||
- const unanswered = await this.held.idle(timeout, signal);
|
||
+ const running = await this.handled.idle(timeout, signal);
|
||
|
||
- if (unanswered === 0) return {};
|
||
+ if (running === 0) return {};
|
||
|
||
- this.log.warn('session - shutting down with messages unanswered', { timeout, unanswered });
|
||
+ this.log.warn('session - shutting down with message handlers still running', { running, timeout });
|
||
|
||
- return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) };
|
||
+ return { err: new Error(`Shut down with ${String(running)} message(s) still being handled`) };
|
||
}
|
||
|
||
- private async unhandled(pduObj: PduObject): Promise<void> {
|
||
+ private unhandled(pduObj: PduObject): void {
|
||
if (bindCommands.includes(pduObj.cmdName)) {
|
||
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
|
||
- await this.session.sendReturn(pduObj, 'ESME_RALYBND', { system_id: this.systemId });
|
||
+ this.answer(pduObj, 'ESME_RALYBND', { system_id: this.systemId });
|
||
|
||
return;
|
||
}
|
||
@@ -185,7 +185,7 @@ export class IncomingRequests {
|
||
}
|
||
|
||
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
|
||
- await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
|
||
+ this.answer(pduObj, 'ESME_RINVCMDID', {});
|
||
}
|
||
|
||
private carriedAs(pduObj: PduObject): string {
|
||
@@ -193,11 +193,11 @@ export class IncomingRequests {
|
||
}
|
||
|
||
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
|
||
- private async onDelivery(pduObj: PduObject): Promise<void> {
|
||
+ private onDelivery(pduObj: PduObject): void {
|
||
const dlr = dlrFromPdu(pduObj, this.smsIdFormat);
|
||
|
||
if (!dlr) {
|
||
- await this.onMessage(pduObj);
|
||
+ this.onMessage(pduObj);
|
||
|
||
return;
|
||
}
|
||
@@ -208,52 +208,30 @@ export class IncomingRequests {
|
||
|
||
if (merged) this.session.emit('messageDlr', merged);
|
||
|
||
- await this.session.sendReturn(pduObj);
|
||
+ this.answer(pduObj, 'ESME_ROK', {});
|
||
}
|
||
|
||
- 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', {
|
||
+ /**
|
||
+ * A concatenated message is answered segment by segment as it arrives: a peer that dispatches
|
||
+ * one request at a time never sends the second segment until the first has been answered.
|
||
+ */
|
||
+ private onMessage(pduObj: PduObject): void {
|
||
+ const carriedAs = this.carriedAs(pduObj);
|
||
+
|
||
+ if (this.handled.refuses()) {
|
||
+ this.log.verbose('session - messages at their bound, asking the peer to retry', {
|
||
cmdName: pduObj.cmdName,
|
||
seqNr: pduObj.seqNr,
|
||
});
|
||
- await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj)));
|
||
+ this.answer(pduObj, retryStatus(carriedAs), {});
|
||
|
||
- 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;
|
||
}
|
||
|
||
- return false;
|
||
- }
|
||
-
|
||
- /**
|
||
- * A concatenated message is answered segment by segment as it arrives: a peer that dispatches
|
||
- * one request at a time never sends the second segment until the first has been answered.
|
||
- */
|
||
- private async onMessage(pduObj: PduObject): Promise<void> {
|
||
- if (await this.refusedAtBound(pduObj)) return;
|
||
-
|
||
const concat = concatOf(pduObj);
|
||
|
||
if (!concat) {
|
||
- this.held.offer([detach(pduObj)]);
|
||
+ this.offer([detach(pduObj)], undefined, carriedAs);
|
||
|
||
return;
|
||
}
|
||
@@ -261,21 +239,24 @@ export class IncomingRequests {
|
||
const collected = this.reassembler.collect(pduObj, concat);
|
||
|
||
if (!collected.kept) {
|
||
- await this.session.sendReturn(
|
||
- pduObj,
|
||
- refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling),
|
||
- );
|
||
+ this.answer(pduObj, refusedSegmentStatus(carriedAs, collected.refusal, concat.spelling), {});
|
||
|
||
return;
|
||
}
|
||
|
||
- await this.session.sendReturn(
|
||
- pduObj,
|
||
- 'ESME_ROK',
|
||
- respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)),
|
||
- );
|
||
+ this.answer(pduObj, 'ESME_ROK', respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)));
|
||
+
|
||
+ if (collected.whole) this.offer(collected.whole, collected.smsId, carriedAs);
|
||
+ }
|
||
+
|
||
+ private offer(pduObjs: PduObject[], answeredAs: string | undefined, carriedAs: string): void {
|
||
+ const generation = this.link.generation();
|
||
|
||
- if (collected.whole) this.held.offer(collected.whole, collected.smsId);
|
||
+ this.handled.offer({ answeredAs, pduObjs, session: this.session }, {
|
||
+ answer: this.answer,
|
||
+ lostLink: () => this.link.generation() !== generation,
|
||
+ send: this.sendReceipt,
|
||
+ }, retryStatus(carriedAs));
|
||
}
|
||
|
||
private reportLost(lost: LostGroup): void {
|
||
diff --git a/src/index.ts b/src/index.ts
|
||
index 71fa973..2fce278 100644
|
||
--- a/src/index.ts
|
||
+++ b/src/index.ts
|
||
@@ -53,6 +53,7 @@ export type {
|
||
export type {
|
||
CloseOptions,
|
||
MessageDlr,
|
||
+ OnSms,
|
||
ReconnectOptions,
|
||
SendOptions,
|
||
SendSmsOptions,
|
||
diff --git a/src/message.ts b/src/message.ts
|
||
index 0665019..2e44b5b 100644
|
||
--- a/src/message.ts
|
||
+++ b/src/message.ts
|
||
@@ -11,7 +11,7 @@ const singleMessageBits = 1120;
|
||
export const maxSegments = 255;
|
||
|
||
/** Budget per segment: the 134 octets left of 140 after the UDH, or the 153 septets GSM packs into them. */
|
||
-const segmentUnits: Record<EncodingName, number> = { ASCII: 153, LATIN1: 134, UCS2: 134 };
|
||
+const segmentUnits: Record<EncodingName, number> = { GSM: 153, LATIN1: 134, UCS2: 134 };
|
||
|
||
export type SplitOptions = {
|
||
encoding?: EncodingName;
|
||
@@ -70,7 +70,7 @@ export function bitCount(message: string, encoding?: EncodingName): number {
|
||
const encoded = encodings[resolved].encode(message);
|
||
|
||
// GSM characters are packed seven bits to a septet; everything else stays octet-aligned.
|
||
- return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8;
|
||
+ return resolved === 'GSM' ? encoded.length * 7 : encoded.length * 8;
|
||
}
|
||
|
||
/**
|
||
diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts
|
||
index a0adf24..827c009 100644
|
||
--- a/src/outgoing-requests.ts
|
||
+++ b/src/outgoing-requests.ts
|
||
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
|
||
import { PendingRequests } from './pending-requests.ts';
|
||
import { SendWindow } from './send-window.ts';
|
||
import { UnansweredError } from './unanswered-error.ts';
|
||
-import { bindCommands } from './session-options.ts';
|
||
+import { bindCommands } from './bind-direction.ts';
|
||
import { objToPdu } from './pdu.ts';
|
||
|
||
export type OutgoingRequestsOptions = {
|
||
@@ -18,6 +18,11 @@ export type OutgoingRequestsOptions = {
|
||
transport: PduTransport;
|
||
};
|
||
|
||
+export type RequestOptions = SendOptions & {
|
||
+ /** A receipt for a message the shutdown is waiting on: the one request a closing session still takes. */
|
||
+ pastDrain?: boolean | undefined;
|
||
+};
|
||
+
|
||
/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */
|
||
type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean };
|
||
|
||
@@ -25,13 +30,6 @@ function abortedBeforeSend(): Error {
|
||
return new Error('Aborted before the request was sent');
|
||
}
|
||
|
||
-/** A response carries the request's sequence number, which only sendReturn() has. */
|
||
-function misuse(input: PduObjectInput): Error | undefined {
|
||
- return input.cmdName.endsWith('_resp')
|
||
- ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`)
|
||
- : undefined;
|
||
-}
|
||
-
|
||
/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */
|
||
export class OutgoingRequests {
|
||
private readonly link: LinkLife;
|
||
@@ -69,35 +67,17 @@ export class OutgoingRequests {
|
||
this.pending.settle(seqNr, { err });
|
||
}
|
||
|
||
- 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.
|
||
- const wrong = misuse(input);
|
||
-
|
||
- if (wrong) return Promise.resolve({ err: wrong });
|
||
-
|
||
- // With no link, the request is refused as closed further on.
|
||
- if (this.link.isStopped() && this.canCarry()) {
|
||
- return Promise.resolve({ err: new Error('Session is shutting down') });
|
||
- }
|
||
-
|
||
- return this.requestPastDrain(input, options);
|
||
- }
|
||
-
|
||
- /** request() without the drain's refusal, which a receipt for a held message has to take. */
|
||
- async requestPastDrain(
|
||
- input: PduObjectInput,
|
||
- options: SendOptions,
|
||
- ): Promise<Result<{ pduObj: PduObject }>> {
|
||
- const refused = this.refuse(input, options);
|
||
+ /**
|
||
+ * A request, on whichever link carries it: waits for a bound link and a window slot, and waits
|
||
+ * again on the next link where nothing of it reached the socket. One budget covers every wait.
|
||
+ */
|
||
+ async request(input: PduObjectInput, options: RequestOptions = {}): Promise<Result<{ pduObj: PduObject }>> {
|
||
+ const refused = this.refusal(input, options);
|
||
|
||
if (refused) return { err: refused };
|
||
|
||
- // 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)) {
|
||
- const shut = this.link.refusal();
|
||
-
|
||
- return shut ? { err: shut } : this.requestOnCurrentLink(input, options);
|
||
- }
|
||
+ // A bind is what makes a link carry anything, so it cannot wait for one.
|
||
+ if (bindCommands.includes(input.cmdName)) return this.requestOnLink(input, options);
|
||
|
||
const waitForLink = this.link.hold(options.signal);
|
||
|
||
@@ -112,15 +92,16 @@ export class OutgoingRequests {
|
||
|
||
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
|
||
|
||
- if (!this.retriesOnNextLink(attempt)) return attempt.result;
|
||
+ if (!attempt.retryOnNextLink || !this.link.awaitsNextLink()) return attempt.result;
|
||
}
|
||
}
|
||
|
||
- /** Straight onto the current link, for what has to go out either way. */
|
||
- async requestOnCurrentLink(
|
||
- input: PduObjectInput,
|
||
- options: SendOptions = {},
|
||
- ): Promise<Result<{ pduObj: PduObject }>> {
|
||
+ /** Straight onto the attached socket, outside the window: a bind or an unbind opens or closes what the window serves. */
|
||
+ async requestOnLink(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> {
|
||
+ const over = this.link.refusal();
|
||
+
|
||
+ if (over) return { err: over };
|
||
+
|
||
return (await this.attempt(input, options)).result;
|
||
}
|
||
|
||
@@ -135,16 +116,21 @@ export class OutgoingRequests {
|
||
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. */
|
||
- private refuse(input: PduObjectInput, options: SendOptions): Error | undefined {
|
||
- // 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);
|
||
+ private refusal(input: PduObjectInput, options: RequestOptions): Error | undefined {
|
||
+ // A response carries the request's sequence number, which only sendReturn() has.
|
||
+ if (input.cmdName.endsWith('_resp')) {
|
||
+ return new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`);
|
||
+ }
|
||
+
|
||
+ if (options.signal?.aborted === true) return abortedBeforeSend();
|
||
+
|
||
+ // With no link up, the link's own refusal names the session closed instead.
|
||
+ if (options.pastDrain !== true && this.link.isStopped() && this.canCarry()) {
|
||
+ return new Error('Session is shutting down');
|
||
+ }
|
||
+
|
||
+ return undefined;
|
||
}
|
||
|
||
private async attempt(input: PduObjectInput, options: SendOptions): Promise<Attempt> {
|
||
diff --git a/src/reassembly.ts b/src/reassembly.ts
|
||
index 4f3d2c5..d6b02bc 100644
|
||
--- a/src/reassembly.ts
|
||
+++ b/src/reassembly.ts
|
||
@@ -2,6 +2,7 @@ import type { Concat } from './concat.ts';
|
||
import type { PduObject } from './pdu.ts';
|
||
import type { SmppLog } from './log.ts';
|
||
import { ExpiringGroups } from './expiring-groups.ts';
|
||
+import { defaults } from './defaults.ts';
|
||
import { decodeMessage } from './message.ts';
|
||
import { detach, retainedOctets } from './retained-pdu.ts';
|
||
import { messageOctets } from './message-body.ts';
|
||
@@ -42,8 +43,6 @@ export type Collected =
|
||
whole?: PduObject[] | undefined;
|
||
};
|
||
|
||
-export const defaultMaxOctets = 64 * 1024 * 1024;
|
||
-
|
||
type Group = {
|
||
parts: Map<number, PduObject>;
|
||
smsId: string;
|
||
@@ -89,7 +88,7 @@ export class Reassembler {
|
||
private readonly onLost: (lost: LostGroup) => void;
|
||
|
||
constructor(options: ReassemblerOptions) {
|
||
- this.maxOctets = options.maxOctets ?? defaultMaxOctets;
|
||
+ this.maxOctets = options.maxOctets ?? defaults.maxReassemblyOctets;
|
||
this.groups = new ExpiringGroups<Group>({
|
||
max: options.max,
|
||
maxWeight: this.maxOctets,
|
||
diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts
|
||
index af8044d..df41afb 100644
|
||
--- a/src/reconnect-loop.ts
|
||
+++ b/src/reconnect-loop.ts
|
||
@@ -1,11 +1,7 @@
|
||
import type { Result, VoidResult } from './result.ts';
|
||
import type { SmppLog } from './log.ts';
|
||
import type { Socket } from 'node:net';
|
||
-
|
||
-export const backoffDefaults = {
|
||
- maxDelay: 30_000,
|
||
- minDelay: 1000,
|
||
-};
|
||
+import { defaults } from './defaults.ts';
|
||
|
||
export type ReconnectLoopOptions = {
|
||
connect: () => Promise<Result<{ sock: Socket }>>;
|
||
@@ -32,8 +28,8 @@ export class ReconnectLoop {
|
||
private upAt: number | undefined;
|
||
|
||
constructor(options: ReconnectLoopOptions) {
|
||
- this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay;
|
||
- this.minDelay = options.minDelay ?? backoffDefaults.minDelay;
|
||
+ this.maxDelay = options.maxDelay ?? defaults.reconnectMaxDelay;
|
||
+ this.minDelay = options.minDelay ?? defaults.reconnectMinDelay;
|
||
this.now = options.now ?? Date.now;
|
||
this.options = options;
|
||
this.delay = this.minDelay;
|
||
diff --git a/src/server.ts b/src/server.ts
|
||
index 2b9aefe..798225e 100644
|
||
--- a/src/server.ts
|
||
+++ b/src/server.ts
|
||
@@ -1,15 +1,17 @@
|
||
-import type { BindType, CloseOptions, OnRequest } from './session-options.ts';
|
||
+import type { BindType } from './bind-direction.ts';
|
||
+import type { CloseOptions, OnRequest, OnSms } from './session-options.ts';
|
||
import type { PduObject, TlvInputs } from './pdu.ts';
|
||
import type { Result, VoidResult } from './result.ts';
|
||
import type { Server as NetServer, Socket } from 'node:net';
|
||
import type { Server as TlsServer, TlsOptions } from 'node:tls';
|
||
import type { SmppLog } from './log.ts';
|
||
import { EventEmitter } from 'node:events';
|
||
-import { Session, defaultSystemId } from './session.ts';
|
||
-import { bindTypeFromCommand, checkSessionOptions } from './session-options.ts';
|
||
+import { Session } from './session.ts';
|
||
+import { bindTypeFromCommand } from './bind-direction.ts';
|
||
+import { checkSessionOptions } from './session-options.ts';
|
||
import { createServer as createNetServer } from 'node:net';
|
||
import { createServer as createTlsServer } from 'node:tls';
|
||
-import { defaultInterfaceVersion } from './defs/constants.ts';
|
||
+import { defaults } from './defaults.ts';
|
||
import { errorFrom } from './error-from.ts';
|
||
import { paramText } from './defs/types.ts';
|
||
import { guardedLog } from './log.ts';
|
||
@@ -35,6 +37,8 @@ export type ServerOptions = {
|
||
maxReassembly?: number;
|
||
/** First refusal on every request a bound peer sends. */
|
||
onRequest?: OnRequest;
|
||
+ /** Takes every message a bound peer submits, on every session. */
|
||
+ onSms?: OnSms;
|
||
port?: number;
|
||
reassemblyTimeout?: number;
|
||
responseTimeout?: number;
|
||
@@ -49,13 +53,6 @@ export type ServerEvents = {
|
||
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. */
|
||
type ServerListener<K extends keyof ServerEvents> = (...args: ServerEvents[K]) => unknown;
|
||
|
||
@@ -239,6 +236,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
|
||
maxOctets: options.maxOctets,
|
||
maxReassembly: options.maxReassembly,
|
||
onRequest: (bound, pduObj) => handleRequest(bound, pduObj, options),
|
||
+ onSms: options.onSms,
|
||
reassemblyTimeout: options.reassemblyTimeout,
|
||
responseTimeout: options.responseTimeout,
|
||
shutdownTimeout: options.shutdownTimeout,
|
||
@@ -269,28 +267,11 @@ function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer {
|
||
return listener;
|
||
}
|
||
|
||
-/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */
|
||
-function checkHooks(options: ServerOptions): VoidResult {
|
||
- for (const name of ['authenticate', 'onRequest'] as const) {
|
||
- const hook: unknown = options[name];
|
||
-
|
||
- if (hook !== undefined && typeof hook !== 'function') {
|
||
- return { err: new Error(`${name} must be a function, got ${typeof hook}`) };
|
||
- }
|
||
- }
|
||
-
|
||
- return {};
|
||
-}
|
||
-
|
||
function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidResult {
|
||
const checked = checkSessionOptions(options);
|
||
|
||
if (checked.err) return { err: checked.err };
|
||
|
||
- const hooks = checkHooks(options);
|
||
-
|
||
- if (hooks.err) return hooks;
|
||
-
|
||
if (options.tls === true) {
|
||
log.warn('server - tls without a certificate', { port });
|
||
|
||
diff --git a/src/session-options.ts b/src/session-options.ts
|
||
index 0b768c9..956ca1d 100644
|
||
--- a/src/session-options.ts
|
||
+++ b/src/session-options.ts
|
||
@@ -8,8 +8,7 @@ import type { SmppLog } from './log.ts';
|
||
import type { SmsIdFormat } from './sms-id.ts';
|
||
import type { Sms } from './sms.ts';
|
||
import type { Socket } from 'node:net';
|
||
-import { backoffDefaults } from './reconnect-loop.ts';
|
||
-import { defaultMaxOctets } from './reassembly.ts';
|
||
+import { defaults } from './defaults.ts';
|
||
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
||
import { namedValue } from './error-from.ts';
|
||
|
||
@@ -23,56 +22,8 @@ export type SessionEvents = {
|
||
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 };
|
||
|
||
/** An already-aborted signal skips the drain; one that fires during it cuts the wait short. */
|
||
@@ -85,6 +36,13 @@ export type CloseOptions = { signal?: AbortSignal | undefined };
|
||
*/
|
||
export type OnRequest = (session: Session, pduObj: PduObject) => Promise<boolean> | boolean;
|
||
|
||
+/**
|
||
+ * Takes every inbound message. The message is held — counted toward the bound, and waited for by
|
||
+ * `close()` — until the returned promise settles. A handler that fails before answering has the
|
||
+ * message answered for it, asking the peer to retry.
|
||
+ */
|
||
+export type OnSms = (sms: Sms) => unknown;
|
||
+
|
||
/**
|
||
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller
|
||
* supplies how to open a socket and what to do once it is open (bind, for a client).
|
||
@@ -104,10 +62,11 @@ export type SessionOptions = {
|
||
maxOutstanding?: number | undefined;
|
||
maxReassembly?: number | undefined;
|
||
onRequest?: OnRequest | undefined;
|
||
+ onSms?: OnSms | undefined;
|
||
reassemblyTimeout?: number | undefined;
|
||
reconnect?: ReconnectOptions | undefined;
|
||
responseTimeout?: number | undefined;
|
||
- /** How long a drain waits for the requests already on the wire. 0 waits forever. */
|
||
+ /** How long a shutdown waits for the handlers still running and the requests already on the wire. 0 waits forever. */
|
||
shutdownTimeout?: number | undefined;
|
||
/** The notation the peer writes message ids in, where it is not the one they are compared in. */
|
||
smsIdFormat?: SmsIdFormat | undefined;
|
||
@@ -116,52 +75,10 @@ export type SessionOptions = {
|
||
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 type SessionBind = { as: BindType; peerVersion: number };
|
||
-
|
||
function quoted(value: unknown): string {
|
||
return typeof value === 'string' ? JSON.stringify(value) : namedValue(value);
|
||
}
|
||
|
||
-function isBindType(value: unknown): value is BindType {
|
||
- return typeof value === 'string' && bindTypeFromCommand(`bind_${value}`) !== undefined;
|
||
-}
|
||
-
|
||
-/** A bind as `Session.bound()` records it: undefined declares no version, which is pre-3.4. */
|
||
-export function checkedBind(bindType: unknown, declaredVersion: unknown): Result<{ bind: SessionBind }> {
|
||
- if (!isBindType(bindType)) {
|
||
- return { err: new Error(`bindType must be receiver, transceiver or transmitter, the bind command's name without "bind_", got ${quoted(bindType)}`) };
|
||
- }
|
||
-
|
||
- if (declaredVersion === undefined) return { bind: { as: bindType, peerVersion: undeclaredInterfaceVersion } };
|
||
-
|
||
- if (typeof declaredVersion !== 'number' || !Number.isInteger(declaredVersion) || declaredVersion < 0 || declaredVersion > 0xFF) {
|
||
- return { err: new Error(`declaredVersion must be an integer 0-255, the interface_version param or the sc_interface_version TLV's tagValue, or undefined where the peer declared none, got ${quoted(declaredVersion)}`) };
|
||
- }
|
||
-
|
||
- return { bind: { as: bindType, peerVersion: declaredVersion } };
|
||
-}
|
||
-
|
||
-export const defaults = {
|
||
- /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
|
||
- dlrMergeTimeout: 86_400_000,
|
||
- /** The peer gave up on an unanswered message long before this; the bound is against growth. */
|
||
- heldMessageTimeout: 300_000,
|
||
- maxDlrMerges: 1000,
|
||
- maxHeldMessages: 1000,
|
||
- maxHeldOctets: 64 * 1024 * 1024,
|
||
- maxOutstanding: 10,
|
||
- maxReassembly: 1000,
|
||
- reassemblyTimeout: 300_000,
|
||
- responseTimeout: 30_000,
|
||
- shutdownTimeout: 5000,
|
||
- systemId: defaultSystemId,
|
||
-};
|
||
-
|
||
/**
|
||
* A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send
|
||
* queued behind a slot that is never freed, so a send with no `signal` never settles at all.
|
||
@@ -171,6 +88,10 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
|
||
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
|
||
}
|
||
|
||
+ const hooks = checkHooks(options);
|
||
+
|
||
+ if (hooks.err) return hooks;
|
||
+
|
||
const connect = checkConnectTimeout(options.connectTimeout);
|
||
|
||
if (connect.err) return connect;
|
||
@@ -184,10 +105,23 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
|
||
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat);
|
||
}
|
||
|
||
+/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */
|
||
+function checkHooks(options: CheckableOptions): VoidResult {
|
||
+ for (const name of ['authenticate', 'onRequest', 'onSms'] as const) {
|
||
+ const hook = options[name];
|
||
+
|
||
+ if (hook !== undefined && typeof hook !== 'function') {
|
||
+ return { err: new Error(`${name} must be a function, got ${typeof hook}`) };
|
||
+ }
|
||
+ }
|
||
+
|
||
+ return {};
|
||
+}
|
||
+
|
||
function limitsOf(options: CheckableOptions): [string, number, number][] {
|
||
return [
|
||
['idleTimeout', options.idleTimeout ?? 0, 0],
|
||
- ['maxOctets', options.maxOctets ?? defaultMaxOctets, 1],
|
||
+ ['maxOctets', options.maxOctets ?? defaults.maxReassemblyOctets, 1],
|
||
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
|
||
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
|
||
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
|
||
@@ -243,8 +177,8 @@ function checkReconnect(reconnect: unknown): VoidResult {
|
||
return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) };
|
||
}
|
||
|
||
- const maxDelay = delayOr(reconnect.maxDelay, backoffDefaults.maxDelay);
|
||
- const minDelay = delayOr(reconnect.minDelay, backoffDefaults.minDelay);
|
||
+ const maxDelay = delayOr(reconnect.maxDelay, defaults.reconnectMaxDelay);
|
||
+ const minDelay = delayOr(reconnect.minDelay, defaults.reconnectMinDelay);
|
||
// 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]]);
|
||
|
||
@@ -292,6 +226,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
|
||
|
||
/** What the checker reads, as it arrives: a caller without types can put anything in it. */
|
||
export type CheckableOptions = {
|
||
+ authenticate?: unknown;
|
||
connectTimeout?: unknown;
|
||
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
|
||
fromStart?: unknown;
|
||
@@ -299,6 +234,8 @@ export type CheckableOptions = {
|
||
maxOctets?: number | undefined;
|
||
maxOutstanding?: number | undefined;
|
||
maxReassembly?: number | undefined;
|
||
+ onRequest?: unknown;
|
||
+ onSms?: unknown;
|
||
reassemblyTimeout?: number | undefined;
|
||
reconnect?: unknown;
|
||
responseTimeout?: number | undefined;
|
||
diff --git a/src/session.ts b/src/session.ts
|
||
index 1fd9b46..67ffa1b 100644
|
||
--- a/src/session.ts
|
||
+++ b/src/session.ts
|
||
@@ -1,9 +1,10 @@
|
||
+import type { BindType, LinkEnd, SessionBind } from './bind-direction.ts';
|
||
import type { ErrorName } from './defs/errors.ts';
|
||
import type { MessageDlr } from './dlr-merger.ts';
|
||
import type { ParamValue } from './defs/types.ts';
|
||
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
|
||
import type { PduRefusedError } from './pdu-refusal.ts';
|
||
-import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts';
|
||
+import type { CloseOptions, OnSms, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
|
||
import type { Result, VoidResult } from './result.ts';
|
||
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
||
import type { SmppLog } from './log.ts';
|
||
@@ -17,9 +18,10 @@ import { OutgoingRequests } from './outgoing-requests.ts';
|
||
import { PduTransport } from './pdu-transport.ts';
|
||
import { ReconnectLoop } from './reconnect-loop.ts';
|
||
import { leftOf } from './idle-waiters.ts';
|
||
+import { bindCarries, bindCommands, checkedBind } from './bind-direction.ts';
|
||
+import { defaults } from './defaults.ts';
|
||
import { errorFrom } from './error-from.ts';
|
||
import { optionalParamsMinVersion } from './defs/constants.ts';
|
||
-import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts';
|
||
import { isResp, objToPdu, pduReturn } from './pdu.ts';
|
||
import { refusalAnswer } from './pdu-refusal.ts';
|
||
import { guardedLog } from './log.ts';
|
||
@@ -29,6 +31,7 @@ import { ConcatReference } from './udh.ts';
|
||
export type {
|
||
CloseOptions,
|
||
MessageDlr,
|
||
+ OnSms,
|
||
ReconnectOptions,
|
||
SendOptions,
|
||
SendSmsOptions,
|
||
@@ -37,7 +40,7 @@ export type {
|
||
SessionOptions,
|
||
};
|
||
export type { BindType };
|
||
-export { bindCommands, defaultSystemId };
|
||
+export { bindCommands };
|
||
|
||
/** 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;
|
||
@@ -63,9 +66,10 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
private readonly dlrMerger: DlrMerger;
|
||
private readonly incoming: IncomingRequests;
|
||
private readonly link: LinkLife;
|
||
- private readonly options: SessionOptions;
|
||
private readonly outgoing: OutgoingRequests;
|
||
private readonly reconnectLoop: ReconnectLoop | undefined;
|
||
+ private readonly smsIdFormat: SessionOptions['smsIdFormat'];
|
||
+ private readonly shutdownTimeout: number;
|
||
private readonly timers: LinkTimers;
|
||
private readonly transport: PduTransport;
|
||
|
||
@@ -93,34 +97,31 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
reason: unknown,
|
||
...args: [event: keyof SessionEvents, ...rest: unknown[]]
|
||
): void {
|
||
- const [event, ...rest] = args;
|
||
+ const [event] = args;
|
||
const error = errorFrom(reason);
|
||
|
||
this.log.error('session - a listener rejected', { event, message: error.message });
|
||
|
||
- if (event === 'sms') this.incoming.listenerRejected(rest[0]);
|
||
-
|
||
if (event !== 'sessionError') this.emit('sessionError', error);
|
||
}
|
||
|
||
constructor(options: SessionOptions) {
|
||
super({ captureRejections: true });
|
||
|
||
+ const responseTimeout = options.responseTimeout ?? defaults.responseTimeout;
|
||
+
|
||
this.log = guardedLog(options.log);
|
||
- this.options = options;
|
||
+ this.smsIdFormat = options.smsIdFormat;
|
||
+ this.shutdownTimeout = options.shutdownTimeout ?? defaults.shutdownTimeout;
|
||
this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout });
|
||
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({
|
||
enquireLinkInterval: options.enquireLinkInterval,
|
||
idleTimeout: options.idleTimeout,
|
||
log: this.log,
|
||
onEnquireLink: () => { void this.send({ cmdName: 'enquire_link' }); },
|
||
- // Not close(): a link that went quiet is a drop, and a drop is what reconnect is for.
|
||
- onIdle: () => { this.teardown(); },
|
||
+ onIdle: () => { this.linkLost(); },
|
||
});
|
||
this.transport = this.transportFor(options.sock);
|
||
this.outgoing = new OutgoingRequests({
|
||
@@ -130,21 +131,39 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
responseTimeout,
|
||
transport: this.transport,
|
||
});
|
||
- this.incoming = new IncomingRequests({
|
||
+ this.incoming = this.incomingFor(options);
|
||
+ this.timers.reset();
|
||
+ }
|
||
+
|
||
+ private transportFor(sock: Socket): PduTransport {
|
||
+ return new PduTransport({
|
||
+ log: this.log,
|
||
+ onClose: () => { this.linkLost(); },
|
||
+ onData: chunk => { this.emit('data', chunk); this.timers.reset(); },
|
||
+ onError: err => { this.emit('sessionError', err); },
|
||
+ onFramed: pdu => { this.emit('incomingPdu', pdu); },
|
||
+ onPdu: pduObj => { this.dispatch(pduObj); },
|
||
+ onRefused: refused => { this.refuse(refused); },
|
||
+ onUnreadable: err => { this.emit('sessionError', err); this.linkLost(); },
|
||
+ }, sock);
|
||
+ }
|
||
+
|
||
+ private incomingFor(options: SessionOptions): IncomingRequests {
|
||
+ return new IncomingRequests({
|
||
+ answer: (pduObj, status, params) => this.answer(pduReturn(pduObj, status, params), pduObj.cmdName, pduObj.seqNr),
|
||
dlrMerger: this.dlrMerger,
|
||
link: this.link,
|
||
log: this.log,
|
||
maxOctets: options.maxOctets,
|
||
maxReassembly: options.maxReassembly,
|
||
onRequest: options.onRequest,
|
||
+ onSms: options.onSms,
|
||
reassemblyTimeout: options.reassemblyTimeout,
|
||
- sendPastDrain: input => this.outgoing.requestPastDrain(input, {}),
|
||
+ sendReceipt: input => this.outgoing.request(input, { pastDrain: true }),
|
||
session: this,
|
||
smsIdFormat: options.smsIdFormat,
|
||
systemId: options.systemId,
|
||
});
|
||
-
|
||
- this.resetTimers();
|
||
}
|
||
|
||
/** Replaced on reconnect, so hold the session rather than this. */
|
||
@@ -196,22 +215,6 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
return Promise.resolve(this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr));
|
||
}
|
||
|
||
- private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
|
||
- 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.
|
||
- if (sent.err && this.link.isAttached()) {
|
||
- this.log.warn('session - could not answer a request', {
|
||
- cmdName,
|
||
- message: sent.err.message,
|
||
- seqNr,
|
||
- });
|
||
- this.emit('sessionError', sent.err);
|
||
- }
|
||
-
|
||
- return sent;
|
||
- }
|
||
-
|
||
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
|
||
if (!this.bindAllows('submit_sm')) {
|
||
return unsent(new Error('A receiver-bound session does not carry submit_sm'));
|
||
@@ -220,7 +223,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
const sent = await submitSms({
|
||
log: this.log,
|
||
reference: this.concatReference.next(),
|
||
- respIdNotation: this.options.smsIdFormat?.submitResp,
|
||
+ respIdNotation: this.smsIdFormat?.submitResp,
|
||
send: input => this.send(input, options),
|
||
}, sms);
|
||
|
||
@@ -230,48 +233,93 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
}
|
||
|
||
/**
|
||
- * Drains, unbinds politely, then closes. Many SMSCs drop the link instead of answering the
|
||
+ * Drains, unbinds politely, then finishes. Many SMSCs drop the link instead of answering the
|
||
* unbind, which is fine. Reports the unbind's own failure ahead of an unfinished drain.
|
||
*/
|
||
async unbind(): Promise<VoidResult> {
|
||
const drained = await this.drain(undefined);
|
||
const wasOpen = this.link.isAttached();
|
||
const sent = wasOpen
|
||
- ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' })
|
||
+ ? await this.outgoing.requestOnLink({ cmdName: 'unbind' })
|
||
: { err: new Error('Session is closed') };
|
||
const closedOnUnbind = wasOpen && !this.link.isAttached();
|
||
|
||
- this.end();
|
||
+ this.finish();
|
||
|
||
return sent.err && !closedOnUnbind ? { err: sent.err } : drained;
|
||
}
|
||
|
||
- /**
|
||
- * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent
|
||
- * and the messages not yet answered, then tears down whatever is left. A session closed this way never reconnects.
|
||
- */
|
||
+ /** Drains, then finishes: the session never reconnects after this. */
|
||
async close(options: CloseOptions = {}): Promise<VoidResult> {
|
||
const drained = await this.drain(options.signal);
|
||
|
||
- this.end();
|
||
+ this.finish();
|
||
|
||
return drained;
|
||
}
|
||
|
||
- private transportFor(sock: Socket): PduTransport {
|
||
- return new PduTransport({
|
||
- log: this.log,
|
||
- onClose: () => { this.onClose(); },
|
||
- onData: chunk => { this.onData(chunk); },
|
||
- onError: err => { this.emit('sessionError', err); },
|
||
- onFramed: pdu => { this.emit('incomingPdu', pdu); },
|
||
- onPdu: pduObj => { this.dispatch(pduObj); },
|
||
- onRefused: refused => { this.refuse(refused); },
|
||
- onUnreadable: err => {
|
||
- this.emit('sessionError', err);
|
||
- this.teardown();
|
||
- },
|
||
- }, sock);
|
||
+ /**
|
||
+ * Takes no new request, waits up to `shutdownTimeout` for the message handlers still running and
|
||
+ * then for the requests already on the wire, and says what did not finish.
|
||
+ */
|
||
+ private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
|
||
+ this.link.stop();
|
||
+ this.reconnectLoop?.stop();
|
||
+
|
||
+ // No bound link, so nothing is on the wire to wait out.
|
||
+ if (!this.outgoing.canCarry()) return {};
|
||
+
|
||
+ const deadline = this.shutdownTimeout > 0 ? Date.now() + this.shutdownTimeout : 0;
|
||
+ // Handlers first: answering a message can put a receipt on the wire; nothing on the wire produces a message.
|
||
+ const handlers = await this.incoming.drain(this.shutdownTimeout, signal);
|
||
+ const requests = await this.outgoing.drain(leftOf(deadline), signal);
|
||
+
|
||
+ // The link went before the drain finished, so an empty window says nothing about the peer.
|
||
+ if (!this.outgoing.canCarry()) return { err: new Error('The session closed before the drain finished') };
|
||
+
|
||
+ if (!handlers.err) return requests;
|
||
+ if (!requests.err) return handlers;
|
||
+
|
||
+ return { err: new Error(`${handlers.err.message}; ${requests.err.message}`) };
|
||
+ }
|
||
+
|
||
+ /** The session is over, drained or not: the link goes, and `close` fires once. */
|
||
+ private finish(): void {
|
||
+ this.link.stop();
|
||
+ this.reconnectLoop?.stop();
|
||
+ this.dropLink();
|
||
+ this.dlrMerger.clear();
|
||
+
|
||
+ if (this.link.end()) this.emit('close');
|
||
+ }
|
||
+
|
||
+ /**
|
||
+ * The one way a link goes: the socket closed, the stream could not be read, the peer went quiet,
|
||
+ * or a rebind failed. Retried where the loop is on, otherwise the end of the session.
|
||
+ */
|
||
+ private linkLost(): void {
|
||
+ const lost = this.dropLink();
|
||
+
|
||
+ if (lost === 'disconnected') {
|
||
+ this.emit('disconnected');
|
||
+ this.reconnectLoop?.schedule();
|
||
+ } else if (lost === 'close') {
|
||
+ this.finish();
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /** Takes the attached socket down and drops what only that link could settle. */
|
||
+ private dropLink(): 'close' | 'disconnected' | undefined {
|
||
+ const lost = this.link.drop();
|
||
+
|
||
+ if (!lost) return undefined;
|
||
+
|
||
+ this.outgoing.linkLost();
|
||
+ this.timers.clear();
|
||
+ this.incoming.clear();
|
||
+ this.sock.destroy();
|
||
+
|
||
+ return lost;
|
||
}
|
||
|
||
private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined {
|
||
@@ -286,28 +334,26 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
});
|
||
}
|
||
|
||
- private async comeBackUp(
|
||
- sock: Socket,
|
||
- bind: (session: Session) => Promise<VoidResult>,
|
||
- ): Promise<VoidResult> {
|
||
- this.attach(sock);
|
||
+ private async comeBackUp(sock: Socket, bind: (session: Session) => Promise<VoidResult>): Promise<VoidResult> {
|
||
+ this.transport.attach(sock);
|
||
+ this.link.attach();
|
||
|
||
const bound = await bind(this);
|
||
|
||
if (bound.err) {
|
||
- this.teardown();
|
||
+ this.linkLost();
|
||
|
||
return { err: bound.err };
|
||
}
|
||
|
||
- // close() can land while the rebind is in flight.
|
||
- if (!this.link.retrying()) {
|
||
- this.teardown();
|
||
+ // close() can land while the rebind is in flight, and so can the peer's FIN.
|
||
+ if (!this.link.retrying() || !this.link.isAttached()) {
|
||
+ this.linkLost();
|
||
|
||
- return { err: new Error('Session closed while it was coming back up') };
|
||
+ return { err: new Error('The link went while it was coming back up') };
|
||
}
|
||
|
||
- this.resetTimers();
|
||
+ this.timers.reset();
|
||
this.link.open();
|
||
this.log.info('session - reconnected');
|
||
this.emit('reconnected');
|
||
@@ -315,84 +361,16 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
return {};
|
||
}
|
||
|
||
- private attach(sock: Socket): void {
|
||
- this.transport.attach(sock);
|
||
- this.link.attach();
|
||
- }
|
||
-
|
||
- /** Stops new sends and waits out the messages we hold and the requests already issued. */
|
||
- private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
|
||
- this.stop();
|
||
-
|
||
- // 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 deadline = timeout > 0 ? Date.now() + timeout : 0;
|
||
- // Answering a message can put a receipt on the wire; nothing on the wire produces a message.
|
||
- const messages = await this.incoming.drain(this.answering(timeout), signal);
|
||
- const requests = await this.outgoing.drain(leftOf(deadline), signal);
|
||
+ private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
|
||
+ const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
|
||
|
||
- // The link went before the drain finished, so an empty window says nothing about the peer.
|
||
- if (!this.outgoing.canCarry()) {
|
||
- return { err: new Error('The session closed before the drain finished') };
|
||
+ // A peer that unbinds and drops the link takes our response with it; that is not a failure.
|
||
+ if (sent.err && this.link.isAttached()) {
|
||
+ this.log.warn('session - could not answer a request', { cmdName, message: sent.err.message, seqNr });
|
||
+ this.emit('sessionError', sent.err);
|
||
}
|
||
|
||
- if (!messages.err) return requests;
|
||
-
|
||
- if (!requests.err) return messages;
|
||
-
|
||
- return { err: new Error(`${messages.err.message}; ${requests.err.message}`) };
|
||
- }
|
||
-
|
||
- /** The application half's budget, which may never be "forever": nothing else ends that wait. */
|
||
- private answering(timeout: number): number {
|
||
- if (timeout > 0) return timeout;
|
||
-
|
||
- const responseTimeout = this.options.responseTimeout ?? defaults.responseTimeout;
|
||
-
|
||
- return responseTimeout > 0 ? responseTimeout : defaults.responseTimeout;
|
||
- }
|
||
-
|
||
- /** The session is over now, drained or not. Nothing brings it back. */
|
||
- private end(): void {
|
||
- this.stop();
|
||
- this.teardown();
|
||
- this.dlrMerger.clear();
|
||
- this.emitClose();
|
||
- }
|
||
-
|
||
- /** No new sends, and no link after this one. */
|
||
- private stop(): void {
|
||
- this.link.stop();
|
||
- this.reconnectLoop?.stop();
|
||
- }
|
||
-
|
||
- private emitClose(): void {
|
||
- if (!this.link.end()) return;
|
||
-
|
||
- this.outgoing.linkLost();
|
||
- this.emit('close');
|
||
- }
|
||
-
|
||
- private teardown(): void {
|
||
- const lost = this.link.drop();
|
||
-
|
||
- if (!lost) return;
|
||
-
|
||
- this.outgoing.linkLost();
|
||
- this.timers.clear();
|
||
- this.incoming.clear();
|
||
- this.sock.destroy();
|
||
-
|
||
- // `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();
|
||
- }
|
||
-
|
||
- private onData(chunk: Buffer): void {
|
||
- this.emit('data', chunk);
|
||
- this.resetTimers();
|
||
+ return sent;
|
||
}
|
||
|
||
private dispatch(pduObj: PduObject): void {
|
||
@@ -405,15 +383,11 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
}
|
||
|
||
this.emit('incomingPduObj', pduObj);
|
||
- // Every application hook and listener reached from an incoming PDU funnels through here.
|
||
+ // The application's onRequest hook is the one thing under here that can throw.
|
||
void this.incoming.handle(pduObj).catch((thrown: unknown) => {
|
||
const err = errorFrom(thrown);
|
||
|
||
- this.log.error('session - a handler threw', {
|
||
- cmdName: pduObj.cmdName,
|
||
- message: err.message,
|
||
- seqNr: pduObj.seqNr,
|
||
- });
|
||
+ this.log.error('session - a request hook threw', { cmdName: pduObj.cmdName, message: err.message, seqNr: pduObj.seqNr });
|
||
this.emit('sessionError', err);
|
||
});
|
||
}
|
||
@@ -433,21 +407,4 @@ export class Session extends EventEmitter<SessionEvents> {
|
||
|
||
this.answer(objToPdu({ ...refusalAnswer(refused), seqNr }), cmdName ?? String(cmdId), seqNr);
|
||
}
|
||
-
|
||
- private resetTimers(): void {
|
||
- if (!this.link.isAttached()) return;
|
||
-
|
||
- this.timers.reset();
|
||
- }
|
||
-
|
||
- private onClose(): void {
|
||
- if (this.link.retrying()) {
|
||
- this.teardown();
|
||
- this.reconnectLoop?.schedule();
|
||
-
|
||
- return;
|
||
- }
|
||
-
|
||
- this.end();
|
||
- }
|
||
}
|
||
diff --git a/src/sms.ts b/src/sms.ts
|
||
index 5149ceb..0f0f8f8 100644
|
||
--- a/src/sms.ts
|
||
+++ b/src/sms.ts
|
||
@@ -1,5 +1,6 @@
|
||
import type { ErrorName } from './defs/errors.ts';
|
||
import type { MessageState } from './defs/constants.ts';
|
||
+import type { ParamValue } from './defs/types.ts';
|
||
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
|
||
import type { Result, VoidResult } from './result.ts';
|
||
import type { Session } from './session.ts';
|
||
@@ -46,9 +47,8 @@ export type Sms = {
|
||
/** Sends a delivery report back to the sender. Defaults to DELIVERED. */
|
||
sendDlr: (status?: MessageState) => Promise<SendDlrResult>;
|
||
/**
|
||
- * Answers the message, and says the application is done with it. A concatenated message was
|
||
- * answered segment by segment as it arrived, so there it only releases a shutdown's wait and
|
||
- * refuses an `smsId` or a refusing `status`. Part of the protocol, not optional.
|
||
+ * Answers the message, once. A concatenated message was answered segment by segment as it
|
||
+ * arrived, so there it writes nothing and refuses an `smsId` or a refusing `status`.
|
||
*/
|
||
sendResp: (options?: SendRespOptions) => Promise<VoidResult>;
|
||
session: Session;
|
||
@@ -65,7 +65,11 @@ export type SmsInput = {
|
||
session: Session;
|
||
};
|
||
|
||
+/** What answering and receipting a message needs from the session it arrived on. */
|
||
export type SmsHandlers = {
|
||
+ /** Writes one response now, on the link the request arrived on. */
|
||
+ answer: (pduObj: PduObject, status: ErrorName, params: Record<string, ParamValue>) => VoidResult;
|
||
+ /** The peer has been answered. */
|
||
answered: () => void;
|
||
lostLink: () => boolean;
|
||
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
|
||
@@ -74,11 +78,13 @@ export type SmsHandlers = {
|
||
/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */
|
||
const immediateDisplayClass = 0;
|
||
|
||
+type Answer = { done: boolean; smsId: string };
|
||
+
|
||
export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
||
const first = input.pduObjs[0];
|
||
const registered = first?.params.registered_delivery;
|
||
const dataCoding = first?.params.data_coding;
|
||
- const answered = { smsId: input.answeredAs ?? uuidv7() };
|
||
+ const answer: Answer = { done: input.answeredAs !== undefined, smsId: input.answeredAs ?? uuidv7() };
|
||
|
||
const sms: Sms = {
|
||
answeredOnArrival: input.answeredAs !== undefined,
|
||
@@ -88,12 +94,12 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
||
message: decodeSegments(input.pduObjs),
|
||
pduObjs: input.pduObjs,
|
||
sendDlr: status => sendDlr(sms, input.session, handlers, status),
|
||
- sendResp: options => (input.answeredAs === undefined
|
||
- ? sendResp(sms, input.session, answered, options ?? {}, handlers)
|
||
- : answeredOnArrival(options ?? {}, handlers)),
|
||
+ sendResp: options => Promise.resolve(input.answeredAs === undefined
|
||
+ ? sendResp(sms, answer, options ?? {}, handlers)
|
||
+ : answeredOnArrival(options ?? {})),
|
||
session: input.session,
|
||
get smsId(): string {
|
||
- return answered.smsId;
|
||
+ return answer.smsId;
|
||
},
|
||
submitTime: new Date(),
|
||
to: paramText(first?.params.destination_addr),
|
||
@@ -102,63 +108,52 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
|
||
return sms;
|
||
}
|
||
|
||
-/** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */
|
||
-function answeredOnArrival(
|
||
- options: SendRespOptions,
|
||
- handlers: Pick<SmsHandlers, 'answered'>,
|
||
-): Promise<VoidResult> {
|
||
+/** Every segment went out answered, so there is nothing left to write and nothing to refuse. */
|
||
+function answeredOnArrival(options: SendRespOptions): VoidResult {
|
||
if (options.smsId !== undefined) {
|
||
- return Promise.resolve({
|
||
- err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId'),
|
||
- });
|
||
+ return { err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId') };
|
||
}
|
||
|
||
if (options.status !== undefined && options.status !== 'ESME_ROK') {
|
||
- return Promise.resolve({
|
||
- err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead'),
|
||
- });
|
||
+ return { err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead') };
|
||
}
|
||
|
||
- handlers.answered();
|
||
-
|
||
- return Promise.resolve({});
|
||
+ return {};
|
||
}
|
||
|
||
-async function sendResp(
|
||
+function sendResp(
|
||
sms: Sms,
|
||
- session: Session,
|
||
- answered: { smsId: string },
|
||
+ answer: Answer,
|
||
options: SendRespOptions,
|
||
- handlers: Pick<SmsHandlers, 'answered' | 'lostLink'>,
|
||
-): Promise<VoidResult> {
|
||
+ handlers: Pick<SmsHandlers, 'answer' | 'answered' | 'lostLink'>,
|
||
+): VoidResult {
|
||
const total = sms.pduObjs.length;
|
||
|
||
- if (total === 0) {
|
||
- return { err: new Error('No PDUs to answer') };
|
||
- }
|
||
-
|
||
- if (options.smsId === '') {
|
||
- return { err: new Error('smsId must not be empty') };
|
||
- }
|
||
-
|
||
- if (options.smsId !== undefined) answered.smsId = options.smsId;
|
||
+ if (answer.done) return { err: new Error('This message was already answered') };
|
||
+ if (total === 0) return { err: new Error('No PDUs to answer') };
|
||
+ if (options.smsId === '') return { err: new Error('smsId must not be empty') };
|
||
|
||
// A response carries the sequence number it was asked on, which the next link knows nothing about.
|
||
if (handlers.lostLink()) {
|
||
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) => session.sendReturn(
|
||
+ const smsId = options.smsId ?? answer.smsId;
|
||
+ const results = sms.pduObjs.map((pduObj, index) => handlers.answer(
|
||
pduObj,
|
||
options.status ?? 'ESME_ROK',
|
||
- respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)),
|
||
- )));
|
||
-
|
||
+ respIdParams(pduObj.cmdName, segmentId(smsId, index, total)),
|
||
+ ));
|
||
const failure = results.find(result => result.err);
|
||
|
||
- if (!failure) handlers.answered();
|
||
+ // Nothing reached the peer, so the message is still unanswered and the id it was given is not its.
|
||
+ if (failure) return failure;
|
||
+
|
||
+ answer.done = true;
|
||
+ answer.smsId = smsId;
|
||
+ handlers.answered();
|
||
|
||
- return failure ?? {};
|
||
+ return {};
|
||
}
|
||
|
||
/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */
|
||
diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts
|
||
index 8132883..44c18e8 100644
|
||
--- a/test/declared-alphabet.test.ts
|
||
+++ b/test/declared-alphabet.test.ts
|
||
@@ -21,7 +21,7 @@ const bothTables = 'Cost 5$ @home';
|
||
/** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */
|
||
const byTheSpecsTable: Record<number, (octets: Buffer) => string> = {
|
||
// The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38.
|
||
- 0x00: octets => encodings.ASCII.decode(octets),
|
||
+ 0x00: octets => encodings.GSM.decode(octets),
|
||
// IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80.
|
||
[consts.ENCODING.IA5]: octets => octets.toString('latin1'),
|
||
};
|
||
@@ -121,17 +121,18 @@ describe('the alphabet a message declares is the one its octets are written in',
|
||
|
||
// sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too.
|
||
test('declares 0x00 on a receipt it writes itself', async t => {
|
||
- const { err, server: smpp } = await server({ port: 0 });
|
||
+ const { err, server: smpp } = await server({
|
||
+ onSms: async sms => {
|
||
+ await sms.sendResp();
|
||
+ await sms.sendDlr('DELIVERED');
|
||
+ },
|
||
+ port: 0,
|
||
+ });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(smpp);
|
||
closeAfter(t, smpp);
|
||
|
||
- smpp.on('session', peer => peer.on('sms', async sms => {
|
||
- await sms.sendResp();
|
||
- await sms.sendDlr('DELIVERED');
|
||
- }));
|
||
-
|
||
const connected = await client({ port: smpp.port, reconnect: false });
|
||
|
||
assert.equal(connected.err, undefined);
|
||
@@ -166,7 +167,7 @@ describe('the alphabet a message declares is the one its octets are written in',
|
||
|
||
describe('what a peer declares is read as generously as it was before', () => {
|
||
test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => {
|
||
- assert.equal(encodingByDataCoding(0x01), 'ASCII');
|
||
+ assert.equal(encodingByDataCoding(0x01), 'GSM');
|
||
|
||
const octets = Buffer.from('436f73742035022000686f6d65', 'hex');
|
||
|
||
diff --git a/test/encodings.test.ts b/test/encodings.test.ts
|
||
index 67fc68c..c2abb0f 100644
|
||
--- a/test/encodings.test.ts
|
||
+++ b/test/encodings.test.ts
|
||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||
import test, { describe } from 'node:test';
|
||
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts';
|
||
|
||
-describe('ASCII (GSM 03.38)', () => {
|
||
+describe('GSM (GSM 03.38)', () => {
|
||
const samples: [string, number[]][] = [
|
||
['@£$¥', [0, 1, 2, 3]],
|
||
[' 1a=', [0x20, 0x31, 0x61, 0x3D]],
|
||
@@ -10,23 +10,23 @@ describe('ASCII (GSM 03.38)', () => {
|
||
];
|
||
|
||
test('matches strings encodable in the GSM 03.38 charset', () => {
|
||
- assert.ok(encodings.ASCII.match(''));
|
||
- assert.ok(encodings.ASCII.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\''));
|
||
- assert.ok(encodings.ASCII.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ'));
|
||
- assert.ok(encodings.ASCII.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'));
|
||
- assert.ok(encodings.ASCII.match('\f^{}\\[~]|€'));
|
||
+ assert.ok(encodings.GSM.match(''));
|
||
+ assert.ok(encodings.GSM.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\''));
|
||
+ assert.ok(encodings.GSM.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ'));
|
||
+ assert.ok(encodings.GSM.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'));
|
||
+ assert.ok(encodings.GSM.match('\f^{}\\[~]|€'));
|
||
});
|
||
|
||
test('rejects strings outside the GSM 03.38 charset', () => {
|
||
- assert.ok(!encodings.ASCII.match('`'));
|
||
- assert.ok(!encodings.ASCII.match('ÁáçÚUÓO'));
|
||
- assert.ok(!encodings.ASCII.match('تست'));
|
||
+ assert.ok(!encodings.GSM.match('`'));
|
||
+ assert.ok(!encodings.GSM.match('ÁáçÚUÓO'));
|
||
+ assert.ok(!encodings.GSM.match('تست'));
|
||
});
|
||
|
||
test('round-trips the sample strings', () => {
|
||
for (const [str, bytes] of samples) {
|
||
- assert.deepEqual(encodings.ASCII.encode(str), Buffer.from(bytes));
|
||
- assert.equal(encodings.ASCII.decode(Buffer.from(bytes)), str);
|
||
+ assert.deepEqual(encodings.GSM.encode(str), Buffer.from(bytes));
|
||
+ assert.equal(encodings.GSM.decode(Buffer.from(bytes)), str);
|
||
}
|
||
});
|
||
});
|
||
@@ -97,8 +97,8 @@ describe('UCS2', () => {
|
||
|
||
describe('detect()', () => {
|
||
test('picks the narrowest encoding that fits the string', () => {
|
||
- assert.equal(detect(''), 'ASCII');
|
||
- assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'ASCII');
|
||
+ assert.equal(detect(''), 'GSM');
|
||
+ assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'GSM');
|
||
assert.equal(detect('`ÁáçÚUÓO'), 'UCS2');
|
||
assert.equal(detect('«©®µ¶±»'), 'UCS2');
|
||
assert.equal(detect('ʹʺʻʼʽ`'), 'UCS2');
|
||
@@ -122,16 +122,16 @@ describe('detect()', () => {
|
||
describe('unencodable()', () => {
|
||
test('names the first character an alphabet cannot carry, and nothing where it carries them all', () => {
|
||
assert.deepEqual(unencodable('あいう', 'LATIN1'), { char: 'あ', index: 0 });
|
||
- assert.deepEqual(unencodable('Åsa naïve', 'ASCII'), { char: 'ï', index: 6 });
|
||
- assert.equal(unencodable('€{}[]\\~^|\f', 'ASCII'), undefined);
|
||
- assert.equal(unencodable('Åsa', 'ASCII'), undefined);
|
||
+ assert.deepEqual(unencodable('Åsa naïve', 'GSM'), { char: 'ï', index: 6 });
|
||
+ assert.equal(unencodable('€{}[]\\~^|\f', 'GSM'), undefined);
|
||
+ assert.equal(unencodable('Åsa', 'GSM'), undefined);
|
||
assert.equal(unencodable('`ÁáçÚ', 'LATIN1'), undefined);
|
||
assert.equal(unencodable('あいう😀', 'UCS2'), undefined);
|
||
});
|
||
|
||
test('counts the index in the units the message is written in, so a surrogate pair reads back whole', () => {
|
||
assert.deepEqual(unencodable('ab😀', 'LATIN1'), { char: '😀', index: 2 });
|
||
- assert.deepEqual(unencodable('a😀b', 'ASCII'), { char: '😀', index: 1 });
|
||
+ assert.deepEqual(unencodable('a😀b', 'GSM'), { char: '😀', index: 1 });
|
||
});
|
||
|
||
test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => {
|
||
@@ -147,36 +147,36 @@ describe('unencodable()', () => {
|
||
});
|
||
|
||
test('reads a character at a time, so a bare GSM escape beside its base reads as carried', () => {
|
||
- assert.equal(unencodable('\x1Be', 'ASCII'), undefined);
|
||
- assert.deepEqual(encodings.ASCII.encode('\x1Be'), encodings.ASCII.encode('€'));
|
||
+ assert.equal(unencodable('\x1Be', 'GSM'), undefined);
|
||
+ assert.deepEqual(encodings.GSM.encode('\x1Be'), encodings.GSM.encode('€'));
|
||
});
|
||
});
|
||
|
||
describe('encodingByDataCoding()', () => {
|
||
test('resolves the flat SMPP data_coding table', () => {
|
||
- assert.equal(encodingByDataCoding(0x00), 'ASCII');
|
||
- assert.equal(encodingByDataCoding(0x01), 'ASCII');
|
||
+ assert.equal(encodingByDataCoding(0x00), 'GSM');
|
||
+ assert.equal(encodingByDataCoding(0x01), 'GSM');
|
||
assert.equal(encodingByDataCoding(0x08), 'UCS2');
|
||
});
|
||
|
||
// 0.4.0 resolved 0x03 to the alias ISO_8859_1, which has no decoder, and silently fell back to
|
||
- // ASCII — every Latin-1 message came out corrupted.
|
||
- test('resolves 0x03 to LATIN1 rather than falling back to ASCII', () => {
|
||
+ // GSM — every Latin-1 message came out corrupted.
|
||
+ test('resolves 0x03 to LATIN1 rather than falling back to GSM', () => {
|
||
assert.equal(encodingByDataCoding(0x03), 'LATIN1');
|
||
});
|
||
|
||
test('reads the alphabet bits when a message class is present', () => {
|
||
- assert.equal(encodingByDataCoding(0x10), 'ASCII');
|
||
- assert.equal(encodingByDataCoding(0x11), 'ASCII');
|
||
+ assert.equal(encodingByDataCoding(0x10), 'GSM');
|
||
+ assert.equal(encodingByDataCoding(0x11), 'GSM');
|
||
assert.equal(encodingByDataCoding(0x18), 'UCS2');
|
||
assert.equal(encodingByDataCoding(0x1A), 'UCS2');
|
||
- assert.equal(encodingByDataCoding(0xF0), 'ASCII');
|
||
- assert.equal(encodingByDataCoding(0xF1), 'ASCII');
|
||
+ assert.equal(encodingByDataCoding(0xF0), 'GSM');
|
||
+ assert.equal(encodingByDataCoding(0xF1), 'GSM');
|
||
});
|
||
|
||
// The compressed and automatic-deletion groups put the alphabet where the plain one does.
|
||
test('reads them in the compressed and automatic-deletion groups too', () => {
|
||
- assert.equal(encodingByDataCoding(0x30), 'ASCII');
|
||
+ assert.equal(encodingByDataCoding(0x30), 'GSM');
|
||
assert.equal(encodingByDataCoding(0x38), 'UCS2');
|
||
assert.equal(encodingByDataCoding(0x54), 'LATIN1');
|
||
assert.equal(encodingByDataCoding(0x58), 'UCS2');
|
||
@@ -196,10 +196,10 @@ describe('encodingByDataCoding()', () => {
|
||
}
|
||
});
|
||
|
||
- test('falls back to ASCII for alphabets it has no codec for', () => {
|
||
- assert.equal(encodingByDataCoding(0x05), 'ASCII');
|
||
- assert.equal(encodingByDataCoding(0x0E), 'ASCII');
|
||
+ test('falls back to GSM for alphabets it has no codec for', () => {
|
||
+ assert.equal(encodingByDataCoding(0x05), 'GSM');
|
||
+ assert.equal(encodingByDataCoding(0x0E), 'GSM');
|
||
// No class, so nothing says the octet is spelled 03.38 rather than SMPP's own flat table.
|
||
- assert.equal(encodingByDataCoding(0x48), 'ASCII');
|
||
+ assert.equal(encodingByDataCoding(0x48), 'GSM');
|
||
});
|
||
});
|
||
diff --git a/test/interop.test.ts b/test/interop.test.ts
|
||
index 2e5d905..f17902f 100644
|
||
--- a/test/interop.test.ts
|
||
+++ b/test/interop.test.ts
|
||
@@ -249,16 +249,14 @@ describe('a live session against the reference implementation', () => {
|
||
});
|
||
|
||
test('a reference client binds to our server and delivers an SMS', async t => {
|
||
- const { err: serverErr, server: smpp } = await server({ port: 0 });
|
||
+ let deliver: (sms: Sms) => void = () => undefined;
|
||
+ const incoming = new Promise<Sms>(resolve => { deliver = resolve; });
|
||
+ const { err: serverErr, server: smpp } = await server({ onSms: sms => { deliver(sms); }, port: 0 });
|
||
|
||
assert.equal(serverErr, undefined);
|
||
assert.ok(smpp);
|
||
closeAfter(t, smpp);
|
||
|
||
- const incoming = new Promise<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
-
|
||
const refSession = reference.connect({
|
||
url: `smpp://localhost:${String(smpp.port)}`,
|
||
});
|
||
diff --git a/test/message-class.test.ts b/test/message-class.test.ts
|
||
index e613139..4718705 100644
|
||
--- a/test/message-class.test.ts
|
||
+++ b/test/message-class.test.ts
|
||
@@ -27,18 +27,19 @@ type MessagePeer = {
|
||
/** A server that answers every message, and a client to write raw submit_sm PDUs at it. */
|
||
async function messagesInto(t: TestContext): Promise<MessagePeer> {
|
||
const received: Sms[] = [];
|
||
- const { err, server: smpp } = await server({ port: 0 });
|
||
+ const { err, server: smpp } = await server({
|
||
+ onSms: sms => {
|
||
+ received.push(sms);
|
||
+
|
||
+ return sms.sendResp();
|
||
+ },
|
||
+ port: 0,
|
||
+ });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(smpp);
|
||
closeAfter(t, smpp);
|
||
|
||
- smpp.on('session', peer => peer.on('sms', sms => {
|
||
- received.push(sms);
|
||
-
|
||
- return sms.sendResp();
|
||
- }));
|
||
-
|
||
const connected = await client({ port: smpp.port, reconnect: false });
|
||
|
||
assert.equal(connected.err, undefined);
|
||
@@ -216,7 +217,7 @@ describe('sendSms() flash', () => {
|
||
const sent = await submitSms(deps, { encoding, from, message: 'Hello world', to });
|
||
|
||
assert.ok(sent.err instanceof Error, JSON.stringify(encoding));
|
||
- assert.match(sent.err.message, /encoding must be ASCII, LATIN1, UCS2/);
|
||
+ assert.match(sent.err.message, /encoding must be GSM, LATIN1, UCS2/);
|
||
assert.deepEqual(sent.smsIds, []);
|
||
}
|
||
|
||
diff --git a/test/message.test.ts b/test/message.test.ts
|
||
index 37392a4..356a525 100644
|
||
--- a/test/message.test.ts
|
||
+++ b/test/message.test.ts
|
||
@@ -124,7 +124,7 @@ describe('splitMessage()', () => {
|
||
|
||
describe('encodeMessage() and decodeMessage()', () => {
|
||
test('picks GSM for GSM-safe text and UCS2 otherwise', () => {
|
||
- assert.equal(encodeMessage('Hello').encoding, 'ASCII');
|
||
+ assert.equal(encodeMessage('Hello').encoding, 'GSM');
|
||
assert.equal(encodeMessage('تست').encoding, 'UCS2');
|
||
});
|
||
|
||
@@ -133,8 +133,8 @@ describe('encodeMessage() and decodeMessage()', () => {
|
||
});
|
||
|
||
// 0.4.0 resolved data_coding 0x03 to the alias ISO_8859_1, which has no decoder, so every
|
||
- // Latin-1 message was silently decoded as ASCII.
|
||
- test('decodes Latin-1 rather than falling back to ASCII', () => {
|
||
+ // Latin-1 message was silently decoded as GSM 03.38.
|
||
+ test('decodes Latin-1 rather than falling back to GSM', () => {
|
||
assert.equal(decodeMessage(Buffer.from([0xE1, 0xE7, 0xDA]), 0x03).message, 'áçÚ');
|
||
});
|
||
|
||
@@ -165,7 +165,7 @@ describe('encodeMessage() and decodeMessage()', () => {
|
||
});
|
||
|
||
describe('the alphabet the encoding helpers are asked for', () => {
|
||
- const everyName: EncodingName[] = ['ASCII', 'LATIN1', 'UCS2'];
|
||
+ const everyName: EncodingName[] = ['GSM', 'LATIN1', 'UCS2'];
|
||
|
||
test('is one of three, each with a codec, so none of the three helpers can reach an absent one', () => {
|
||
assert.deepEqual(Object.keys(encodings).sort(), [...everyName].sort());
|
||
diff --git a/test/readme.test.ts b/test/readme.test.ts
|
||
index f2198a5..ae39ec8 100644
|
||
--- a/test/readme.test.ts
|
||
+++ b/test/readme.test.ts
|
||
@@ -26,21 +26,20 @@ function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
|
||
}
|
||
|
||
/** The README's examples listen on the documented default port, so one server runs at a time. */
|
||
-async function answeringServer(t: TestContext): Promise<SmppServer> {
|
||
- const { err, server: smpp } = await server();
|
||
-
|
||
- assert.equal(err, undefined);
|
||
- assert.ok(smpp);
|
||
- closeAfter(t, smpp);
|
||
-
|
||
- smpp.on('session', session => {
|
||
- session.on('sms', async sms => {
|
||
+async function answeringServer(t: TestContext, seen?: (sms: Sms) => void): Promise<SmppServer> {
|
||
+ const { err, server: smpp } = await server({
|
||
+ onSms: async sms => {
|
||
+ seen?.(sms);
|
||
await sms.sendResp();
|
||
|
||
if (sms.dlr) await sms.sendDlr();
|
||
- });
|
||
+ },
|
||
});
|
||
|
||
+ assert.equal(err, undefined);
|
||
+ assert.ok(smpp);
|
||
+ closeAfter(t, smpp);
|
||
+
|
||
return smpp;
|
||
}
|
||
|
||
@@ -121,10 +120,11 @@ describe('README: Client', () => {
|
||
});
|
||
|
||
test('the documented sending options', async t => {
|
||
- const smpp = await answeringServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ let deliver: (sms: Sms) => void = () => undefined;
|
||
+ const incoming = once<Sms>(resolve => { deliver = resolve; });
|
||
+
|
||
+ await answeringServer(t, sms => { deliver(sms); });
|
||
+
|
||
const { err, session } = await client();
|
||
if (err) throw err;
|
||
|
||
@@ -154,12 +154,19 @@ describe('README: Client', () => {
|
||
test('receiving an inbound message on a client session', async t => {
|
||
const smpp = await answeringServer(t);
|
||
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
|
||
- const { err, session } = await client();
|
||
+ let deliver: (sms: Sms) => void = () => undefined;
|
||
+ const incoming = once<Sms>(resolve => { deliver = resolve; });
|
||
+ const { err, session } = await client({
|
||
+ onSms: async sms => {
|
||
+ // sms.from, sms.to, sms.message
|
||
+ await sms.sendResp();
|
||
+ deliver(sms);
|
||
+ },
|
||
+ });
|
||
if (err) throw err;
|
||
|
||
closeAfter(t, session);
|
||
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
const peer = await bound;
|
||
|
||
void peer.send({
|
||
@@ -173,27 +180,22 @@ describe('README: Client', () => {
|
||
|
||
const sms = await incoming;
|
||
|
||
- await sms.sendResp();
|
||
-
|
||
assert.equal(sms.message, 'inbound hello');
|
||
});
|
||
});
|
||
|
||
describe('README: Server', () => {
|
||
test('the simplest possible server', async t => {
|
||
- const { err, server: smpp } = await server();
|
||
- if (err) throw err;
|
||
-
|
||
- closeAfter(t, smpp);
|
||
-
|
||
const received: string[] = [];
|
||
-
|
||
- smpp.on('session', session => {
|
||
- session.on('sms', async sms => {
|
||
+ const { err, server: smpp } = await server({
|
||
+ onSms: async sms => {
|
||
received.push(sms.message);
|
||
await sms.sendResp();
|
||
- });
|
||
+ },
|
||
});
|
||
+ if (err) throw err;
|
||
+
|
||
+ closeAfter(t, smpp);
|
||
|
||
const { err: clientErr, session } = await client();
|
||
|
||
@@ -208,25 +210,20 @@ describe('README: Server', () => {
|
||
});
|
||
|
||
test('with authentication and delivery reports', async t => {
|
||
+ let answeredOnArrival: boolean | undefined;
|
||
+ let userData: unknown;
|
||
const { err, server: smpp } = await server({
|
||
authenticate: ({ password, systemId }) => {
|
||
if (systemId !== 'foo' || password !== 'bar') return false;
|
||
|
||
return { userData: { userId: 123 } };
|
||
},
|
||
- });
|
||
- if (err) throw err;
|
||
-
|
||
- closeAfter(t, smpp);
|
||
-
|
||
- let answeredOnArrival: boolean | undefined;
|
||
-
|
||
- smpp.on('session', session => {
|
||
- session.on('sms', async sms => {
|
||
+ onSms: async sms => {
|
||
+ userData = sms.session.userData;
|
||
answeredOnArrival = sms.answeredOnArrival;
|
||
|
||
if (sms.answeredOnArrival) {
|
||
- await sms.sendResp(); // multipart: only releases the shutdown drain
|
||
+ await sms.sendResp(); // multipart: already answered per segment, so this writes nothing
|
||
} else {
|
||
await sms.sendResp(); // ESME_ROK with a generated id
|
||
}
|
||
@@ -234,8 +231,11 @@ describe('README: Server', () => {
|
||
if (sms.dlr) {
|
||
await sms.sendDlr();
|
||
}
|
||
- });
|
||
+ },
|
||
});
|
||
+ if (err) throw err;
|
||
+
|
||
+ closeAfter(t, smpp);
|
||
|
||
assert.equal(smpp.port, 2775);
|
||
|
||
@@ -260,10 +260,12 @@ describe('README: Server', () => {
|
||
assert.equal(sent.err, undefined);
|
||
assert.equal((await reported).statusMsg, 'DELIVERED');
|
||
assert.equal(answeredOnArrival, false);
|
||
+ assert.deepEqual(userData, { userId: 123 });
|
||
});
|
||
|
||
test('refusing a segment at onRequest, before this library would answer it', async t => {
|
||
const knownRecipients = new Set(['46709771337']);
|
||
+ let messages = 0;
|
||
const { err, server: smpp } = await server({
|
||
onRequest: async (session, pduObj) => {
|
||
if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) {
|
||
@@ -274,15 +276,12 @@ describe('README: Server', () => {
|
||
|
||
return true;
|
||
},
|
||
+ onSms: () => { messages++; },
|
||
});
|
||
if (err) throw err;
|
||
|
||
closeAfter(t, smpp);
|
||
|
||
- let messages = 0;
|
||
-
|
||
- smpp.on('session', bound => bound.on('sms', () => { messages++; }));
|
||
-
|
||
const connected = await client();
|
||
if (connected.err) throw connected.err;
|
||
|
||
diff --git a/test/session-error.test.ts b/test/session-error.test.ts
|
||
index af9b3b9..7cc97e1 100644
|
||
--- a/test/session-error.test.ts
|
||
+++ b/test/session-error.test.ts
|
||
@@ -38,8 +38,12 @@ async function waitFor(condition: () => boolean, budget = 2000): Promise<boolean
|
||
}
|
||
|
||
/** A bound client, and the server session at the far end whose socket the tests write from. */
|
||
-async function linked(t: TestContext, options: Parameters<typeof client>[0] = {}) {
|
||
- const { err, server: smpp } = await server({ port: 0 });
|
||
+async function linked(
|
||
+ t: TestContext,
|
||
+ options: Parameters<typeof client>[0] = {},
|
||
+ serverOptions: Parameters<typeof server>[0] = {},
|
||
+) {
|
||
+ const { err, server: smpp } = await server({ ...serverOptions, port: 0 });
|
||
|
||
assert.equal(err, undefined);
|
||
assert.ok(smpp);
|
||
@@ -94,19 +98,19 @@ describe('telling a refused PDU from a failed session', () => {
|
||
);
|
||
});
|
||
|
||
- test('reports a listener that threw as an error that is no refusal', async t => {
|
||
- const { peer, session } = await linked(t, { responseTimeout: 200 });
|
||
+ test('reports a handler that threw as an error that is no refusal', async t => {
|
||
+ const { peer, session } = await linked(t, { responseTimeout: 200 }, {
|
||
+ onSms: () => { throw new Error('handler exploded'); },
|
||
+ });
|
||
const failed = once<Error>(resolve => { peer.on('sessionError', resolve); });
|
||
|
||
- peer.on('sms', () => { throw new Error('listener exploded'); });
|
||
-
|
||
- await session.sendSms({ from: '46701113311', message: 'blows the listener up', to: '46709771337' });
|
||
+ await session.sendSms({ from: '46701113311', message: 'blows the handler up', to: '46709771337' });
|
||
|
||
const reported = await raceWithin(2000, failed);
|
||
|
||
- assert.ok(reported, 'the listener that threw never reached the session');
|
||
+ assert.ok(reported, 'the handler that threw never reached the session');
|
||
assert.ok(!(reported instanceof PduRefusedError), 'a session failure is not a refused PDU');
|
||
- assert.equal(reported.message, 'listener exploded');
|
||
+ assert.equal(reported.message, 'handler exploded');
|
||
});
|
||
|
||
test('reports a socket the peer reset as an error that is no refusal', async t => {
|
||
diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts
|
||
index 6b36279..16f9c74 100644
|
||
--- a/test/session-extras.test.ts
|
||
+++ b/test/session-extras.test.ts
|
||
@@ -5,9 +5,9 @@ import type { Collected, LostGroup } from '../src/reassembly.ts';
|
||
import type { Dlr } from '../src/dlr.ts';
|
||
import type { ErrorName } from '../src/defs/errors.ts';
|
||
import type { IncomingRequestsOptions } from '../src/incoming-requests.ts';
|
||
-import type { HeldMessagesOptions, MessageHold } from '../src/held-messages.ts';
|
||
+import type { HandledMessagesOptions, SmsRoute } from '../src/handled-messages.ts';
|
||
import type { MessageState } from '../src/defs/constants.ts';
|
||
-import type { MessageDlr } from '../src/session.ts';
|
||
+import type { MessageDlr, OnSms } from '../src/session.ts';
|
||
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
|
||
import type { Result } from '../src/result.ts';
|
||
import type { SendSmsResult } from '../src/send-sms.ts';
|
||
@@ -15,7 +15,7 @@ import type { SmppLog } from '../src/log.ts';
|
||
import type { Sms } from '../src/sms.ts';
|
||
import type { SmppServer } from '../src/server.ts';
|
||
import type { TestContext } from 'node:test';
|
||
-import { HeldMessages } from '../src/held-messages.ts';
|
||
+import { HandledMessages } from '../src/handled-messages.ts';
|
||
import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts';
|
||
import { UnansweredError } from '../src/unanswered-error.ts';
|
||
import { createSms } from '../src/sms.ts';
|
||
@@ -26,7 +26,9 @@ import { Session } from '../src/session.ts';
|
||
import { DlrMerger } from '../src/dlr-merger.ts';
|
||
import { PduRefusedError } from '../src/pdu-refusal.ts';
|
||
import { objToPdu } from '../src/pdu.ts';
|
||
-import { checkSessionOptions, defaults, standsInFor } from '../src/session-options.ts';
|
||
+import { checkSessionOptions } from '../src/session-options.ts';
|
||
+import { defaults } from '../src/defaults.ts';
|
||
+import { standsInFor } from '../src/bind-direction.ts';
|
||
import { client } from '../src/client.ts';
|
||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||
import { concatOf } from '../src/concat.ts';
|
||
@@ -86,6 +88,16 @@ function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
|
||
return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
|
||
}
|
||
|
||
+type Inbox = { onSms: (sms: Sms) => void; sms: Promise<Sms> };
|
||
+
|
||
+/** Hands the test the first message and returns at once, so nothing is held. */
|
||
+function inbox(): Inbox {
|
||
+ let deliver: (sms: Sms) => void = () => undefined;
|
||
+ const sms = once<Sms>(resolve => { deliver = resolve; });
|
||
+
|
||
+ return { onSms: message => { deliver(message); }, sms };
|
||
+}
|
||
+
|
||
/** A client still retrying holds a socket and a timer nothing else releases. */
|
||
function abortAfter(
|
||
t: TestContext,
|
||
@@ -103,15 +115,34 @@ function abortAfter(
|
||
|
||
function incomingOn(session: Session, options: Partial<IncomingRequestsOptions> = {}): IncomingRequests {
|
||
return new IncomingRequests({
|
||
+ // Through the session, so a test that stubs sendReturn() sees every answer.
|
||
+ answer: (pduObj, status, params) => { void session.sendReturn(pduObj, status, params); return {}; },
|
||
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
||
link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }),
|
||
log: silentLog,
|
||
- sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
|
||
+ sendReceipt: () => Promise.resolve({ err: new Error('never sent') }),
|
||
session,
|
||
...options,
|
||
});
|
||
}
|
||
|
||
+/** A handler that returns only when the test lets it, so the message stays handled until then. */
|
||
+function holding(): { onSms: OnSms; release: () => void } {
|
||
+ const released = latch();
|
||
+
|
||
+ return { onSms: () => released.passed, release: released.open };
|
||
+}
|
||
+
|
||
+/** A handler that never returns: what an application stuck on a message looks like. */
|
||
+const stuck: OnSms = () => new Promise(() => undefined);
|
||
+
|
||
+/** How a message reaches the session it is offered on, with nothing on the wire. */
|
||
+const noRoute: SmsRoute = {
|
||
+ answer: () => ({}),
|
||
+ lostLink: () => false,
|
||
+ send: () => Promise.resolve({ err: new Error('never sent') }),
|
||
+};
|
||
+
|
||
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
|
||
return {
|
||
cmdId: 0x00000004,
|
||
@@ -170,10 +201,8 @@ function latch(): Latch {
|
||
describe('merged delivery reports', () => {
|
||
// 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it.
|
||
test('reports once on a whole multipart message', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
@@ -184,7 +213,7 @@ describe('merged delivery reports', () => {
|
||
session.on('dlr', dlr => perSegment.push(dlr.smsId ?? ''));
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -208,10 +237,8 @@ describe('merged delivery reports', () => {
|
||
});
|
||
|
||
test('reports once, on the final receipts, when the peer reports en route first', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
@@ -226,7 +253,7 @@ describe('merged delivery reports', () => {
|
||
});
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -264,10 +291,8 @@ describe('merged delivery reports', () => {
|
||
});
|
||
|
||
test('reports the worst status across the segments', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
@@ -275,7 +300,7 @@ describe('merged delivery reports', () => {
|
||
const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -378,16 +403,14 @@ describe('sendSms()', () => {
|
||
}
|
||
|
||
test('reports a submit_sm the peer refused instead of an empty message id', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
|
||
const [, sent] = await Promise.all([
|
||
- incoming.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })),
|
||
+ incoming.sms.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })),
|
||
session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }),
|
||
]);
|
||
|
||
@@ -602,15 +625,13 @@ describe('reconnect', () => {
|
||
});
|
||
|
||
test('re-binds after the connection drops, keeping the same session object', async t => {
|
||
- const smpp = await startServer(t);
|
||
const messages: string[] = [];
|
||
-
|
||
- // Registered up front so the session created by the reconnect is covered too.
|
||
- smpp.on('session', bound => {
|
||
- bound.on('sms', sms => {
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: sms => {
|
||
messages.push(sms.message);
|
||
- void sms.sendResp();
|
||
- });
|
||
+
|
||
+ return sms.sendResp();
|
||
+ },
|
||
});
|
||
|
||
const { err, session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
|
||
@@ -652,12 +673,13 @@ describe('reconnect', () => {
|
||
});
|
||
|
||
test('merges the receipts of a multipart message across a drop', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', sms => {
|
||
- resolve(sms);
|
||
- void sms.sendResp();
|
||
- }));
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: sms => {
|
||
+ incoming.onSms(sms);
|
||
+
|
||
+ return sms.sendResp();
|
||
+ },
|
||
});
|
||
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
|
||
|
||
@@ -669,7 +691,7 @@ describe('reconnect', () => {
|
||
message: 'x'.repeat(400),
|
||
to: '46709771337',
|
||
});
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
|
||
assert.deepEqual(sent.smsIds, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`));
|
||
|
||
@@ -694,19 +716,19 @@ describe('reconnect', () => {
|
||
|
||
test('refuses to answer a message whose link went, held or already answered', async t => {
|
||
const smpp = await startServer(t);
|
||
- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
|
||
-
|
||
- assert.ok(session);
|
||
-
|
||
const arrived: Sms[] = [];
|
||
- const both = once<true>(resolve => {
|
||
- session.on('sms', sms => {
|
||
+ const both = latch();
|
||
+ const { session } = await connect(t, smpp, {
|
||
+ onSms: sms => {
|
||
arrived.push(sms);
|
||
|
||
- if (arrived.length === 2) resolve(true);
|
||
- });
|
||
+ if (arrived.length === 2) both.open();
|
||
+ },
|
||
+ reconnect: { maxDelay: 100, minDelay: 20 },
|
||
});
|
||
|
||
+ assert.ok(session);
|
||
+
|
||
for (const text of ['answered before the drop', 'never answered']) {
|
||
void peerOf(smpp).send({
|
||
cmdName: 'deliver_sm',
|
||
@@ -718,7 +740,7 @@ describe('reconnect', () => {
|
||
});
|
||
}
|
||
|
||
- await both;
|
||
+ await both.passed;
|
||
|
||
const [answered, held] = arrived;
|
||
|
||
@@ -737,7 +759,7 @@ describe('reconnect', () => {
|
||
// A response is dispatched before `incomingPduObj`, so only the raw event sees one arrive.
|
||
peerOf(smpp).on('incomingPdu', () => { taken++; });
|
||
|
||
- assert.match((await answered.sendResp()).err?.message ?? '', /link this message arrived on is gone/);
|
||
+ assert.match((await answered.sendResp()).err?.message ?? '', /already answered/);
|
||
assert.match((await held.sendResp()).err?.message ?? '', /link this message arrived on is gone/);
|
||
|
||
assert.equal((await held.sendDlr('DELIVERED')).err, undefined);
|
||
@@ -750,10 +772,12 @@ describe('reconnect', () => {
|
||
closeAfter(t, session);
|
||
|
||
const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
|
||
- const incoming = incomingOn(session, { link, onRequest: async () => { await delay(10); return false; } });
|
||
let messages = 0;
|
||
-
|
||
- session.on('sms', () => { messages++; });
|
||
+ const incoming = incomingOn(session, {
|
||
+ link,
|
||
+ onRequest: async () => { await delay(10); return false; },
|
||
+ onSms: () => { messages++; },
|
||
+ });
|
||
|
||
const handled = incoming.handle(submitPdu(1));
|
||
|
||
@@ -1148,27 +1172,23 @@ describe('connectTimeout', () => {
|
||
|
||
describe('sends across a reconnect', () => {
|
||
/** Answers every message after the first, which is left to hold the send window open. */
|
||
- function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
|
||
+ function answerAfterTheFirst(arrived: string[]): { first: Latch; onSms: OnSms } {
|
||
const first = latch();
|
||
|
||
- smpp.on('session', peer => {
|
||
- peer.on('sms', async sms => {
|
||
+ return {
|
||
+ first,
|
||
+ onSms: async sms => {
|
||
arrived.push(sms.message);
|
||
|
||
if (arrived.length === 1) first.open();
|
||
else await sms.sendResp();
|
||
- });
|
||
- });
|
||
-
|
||
- return first;
|
||
+ },
|
||
+ };
|
||
}
|
||
|
||
test('holds a send issued while the link is down and puts it on the new link', async t => {
|
||
- const smpp = await startServer(t);
|
||
const arrived: string[] = [];
|
||
-
|
||
- smpp.on('session', peer => { peer.on('sms', async sms => { arrived.push(sms.message); await sms.sendResp(); }); });
|
||
-
|
||
+ const smpp = await startServer(t, { onSms: async sms => { arrived.push(sms.message); await sms.sendResp(); } });
|
||
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
|
||
|
||
assert.ok(session);
|
||
@@ -1187,9 +1207,9 @@ describe('sends across a reconnect', () => {
|
||
});
|
||
|
||
test('puts a segment still queued behind a full window on the new link', async t => {
|
||
- const smpp = await startServer(t);
|
||
const arrived: string[] = [];
|
||
- const first = answerAfterTheFirst(smpp, arrived);
|
||
+ const { first, onSms } = answerAfterTheFirst(arrived);
|
||
+ const smpp = await startServer(t, { onSms });
|
||
const { session } = await connect(t, smpp, {
|
||
maxOutstanding: 1,
|
||
reconnect: { maxDelay: 100, minDelay: 20 },
|
||
@@ -1228,10 +1248,8 @@ describe('sends across a reconnect', () => {
|
||
|
||
return true;
|
||
},
|
||
+ onSms: sms => sms.sendResp(),
|
||
});
|
||
-
|
||
- smpp.on('session', peer => { peer.on('sms', async sms => { await sms.sendResp(); }); });
|
||
-
|
||
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
|
||
|
||
assert.ok(session);
|
||
@@ -1258,10 +1276,7 @@ describe('sends across a reconnect', () => {
|
||
});
|
||
|
||
test('counts a segment the peer never answered in time as unanswered', async t => {
|
||
- const smpp = await startServer(t);
|
||
-
|
||
- smpp.on('session', peer => { peer.on('sms', () => undefined); });
|
||
-
|
||
+ const smpp = await startServer(t, { onSms: () => undefined });
|
||
const { session } = await connect(t, smpp, { responseTimeout: 200 });
|
||
|
||
assert.ok(session);
|
||
@@ -1273,8 +1288,8 @@ describe('sends across a reconnect', () => {
|
||
});
|
||
|
||
test('counts a segment aborted after it went out as unanswered', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const arrived = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
|
||
+ const arrived = inbox();
|
||
+ const smpp = await startServer(t, { onSms: arrived.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
@@ -1285,7 +1300,7 @@ describe('sends across a reconnect', () => {
|
||
{ signal: controller.signal },
|
||
);
|
||
|
||
- await arrived;
|
||
+ await arrived.sms;
|
||
controller.abort();
|
||
|
||
const sent = await sending;
|
||
@@ -1295,15 +1310,15 @@ describe('sends across a reconnect', () => {
|
||
});
|
||
|
||
test('reports a segment the link dropped under as unanswered, not as never sent', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const arrived = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
|
||
+ const arrived = inbox();
|
||
+ const smpp = await startServer(t, { onSms: arrived.onSms });
|
||
const { session } = await connect(t, smpp, { reconnect: false });
|
||
|
||
assert.ok(session);
|
||
|
||
const sending = session.sendSms({ from: '46701113311', message: 'in flight', to: '46709771337' });
|
||
|
||
- await arrived;
|
||
+ await arrived.sms;
|
||
peerOf(smpp).sock.destroy();
|
||
|
||
const sent = await sending;
|
||
@@ -1536,125 +1551,126 @@ describe('SendWindow', () => {
|
||
});
|
||
});
|
||
|
||
-// Goal 4: an application that answers nothing must not grow this for the life of the link.
|
||
-describe('held message bounds', () => {
|
||
- function message(seqNr: number): PduObject[] {
|
||
- return [submitPdu(seqNr)];
|
||
- }
|
||
-
|
||
- function offer(held: HeldMessages, seqNr: number): MessageHold {
|
||
- const hold = held.offer(message(seqNr));
|
||
-
|
||
- assert.ok(hold);
|
||
-
|
||
- return hold;
|
||
- }
|
||
+// Goal 4: an application whose handlers never return must not grow this for the life of the link.
|
||
+describe('handled message bounds', () => {
|
||
+ type Offered = { release: () => void; sms: Sms };
|
||
|
||
- /** Offers to a session with a listener, so an offer is held rather than released as untaken. */
|
||
- function heldOn(
|
||
+ function handledOn(
|
||
t: TestContext,
|
||
- options: Pick<HeldMessagesOptions, 'max' | 'maxOctets' | 'now' | 'timeout'>,
|
||
- ): HeldMessages {
|
||
+ options: Pick<HandledMessagesOptions, 'max' | 'maxOctets' | 'now' | 'timeout'>,
|
||
+ ): { handled: HandledMessages; offer: (seqNr: number) => Offered } {
|
||
const session = new Session({ sock: new net.Socket() });
|
||
-
|
||
- closeAfter(t, session);
|
||
- session.on('sms', () => undefined);
|
||
-
|
||
- return new HeldMessages({
|
||
+ const releases = new Map<Sms, () => void>();
|
||
+ const handled = new HandledMessages({
|
||
...options,
|
||
- link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }),
|
||
log: silentLog,
|
||
- sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
|
||
- session,
|
||
+ onSms: sms => new Promise<void>(resolve => { releases.set(sms, resolve); }),
|
||
+ report: () => undefined,
|
||
});
|
||
+
|
||
+ closeAfter(t, session);
|
||
+
|
||
+ return {
|
||
+ handled,
|
||
+ offer: seqNr => {
|
||
+ const sms = handled.offer({ pduObjs: [submitPdu(seqNr)], session }, noRoute, 'ESME_RTHROTTLED');
|
||
+
|
||
+ return { release: () => { releases.get(sms)?.(); }, sms };
|
||
+ },
|
||
+ };
|
||
}
|
||
|
||
- test('is full at its count, and a re-used sequence number replaces rather than adding', t => {
|
||
- const held = heldOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 });
|
||
- const first = offer(held, 1);
|
||
- const replaced = offer(held, 2);
|
||
+ function settled(): Promise<void> {
|
||
+ return new Promise(resolve => { setImmediate(resolve); });
|
||
+ }
|
||
|
||
- offer(held, 2);
|
||
+ test('is full at its count until a handler returns', async t => {
|
||
+ const { handled, offer } = handledOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 });
|
||
+ const first = offer(1);
|
||
|
||
- assert.equal(held.size, 2);
|
||
- assert.equal(held.octetsHeld, 2 * 1026, 'the replaced message leaves its octets with it');
|
||
- assert.equal(held.full(), true);
|
||
- assert.equal(first.isHeld(), true);
|
||
- assert.equal(replaced.isHeld(), false);
|
||
+ offer(2);
|
||
|
||
- held.clear();
|
||
+ assert.equal(handled.size, 2);
|
||
+ assert.equal(handled.octets, 2 * 1026, 'each message weighs its object and its three text fields');
|
||
+ assert.equal(handled.refuses(), true);
|
||
+
|
||
+ first.release();
|
||
+ await settled();
|
||
+
|
||
+ assert.equal(handled.size, 1);
|
||
+ assert.equal(handled.refuses(), false);
|
||
+ handled.clear();
|
||
});
|
||
|
||
// submitPdu() holds 1026 octets by the maxOctets charge: its object, and the three text fields.
|
||
- test('is full at its octet cap, until a message leaves by any way out', t => {
|
||
+ test('is full at its octet cap, until a message leaves by any way out', async t => {
|
||
let now = 0;
|
||
- const held = heldOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 });
|
||
- const answered = offer(held, 1);
|
||
+ const { handled, offer } = handledOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 });
|
||
+ const returned = offer(1);
|
||
|
||
- assert.equal(held.full(), false);
|
||
- offer(held, 2);
|
||
- assert.equal(held.full(), true);
|
||
+ assert.equal(handled.refuses(), false);
|
||
+ offer(2);
|
||
+ assert.equal(handled.refuses(), true);
|
||
|
||
- answered.release();
|
||
- assert.equal(held.full(), false, 'after a release');
|
||
- offer(held, 3);
|
||
+ returned.release();
|
||
+ await settled();
|
||
+ assert.equal(handled.refuses(), false, 'after the handler returned');
|
||
+ offer(3);
|
||
|
||
now = 20_000;
|
||
- held.sweep();
|
||
+ handled.sweep();
|
||
now = 0;
|
||
- assert.equal(held.full(), false, 'after a sweep');
|
||
- offer(held, 4);
|
||
- offer(held, 5);
|
||
+ assert.equal(handled.refuses(), false, 'after a sweep');
|
||
+ offer(4);
|
||
+ offer(5);
|
||
|
||
- held.clear();
|
||
- assert.equal(held.full(), false, 'after a clear');
|
||
+ handled.clear();
|
||
+ assert.equal(handled.refuses(), false, 'after a clear');
|
||
});
|
||
|
||
- // Dropping one the application still holds frees nothing, and the drain stops waiting for it.
|
||
+ // Dropping one the handler still holds frees nothing, and the drain stops waiting for it.
|
||
test('refuses what arrives past the bound with a status that asks the peer to retry', async t => {
|
||
const session = new Session({ sock: new net.Socket() });
|
||
|
||
closeAfter(t, session);
|
||
|
||
const warnings: string[] = [];
|
||
- const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } });
|
||
const answers: (ErrorName | undefined)[] = [];
|
||
const received: Sms[] = [];
|
||
+ const releases: (() => void)[] = [];
|
||
+ const incoming = incomingOn(session, {
|
||
+ answer: (_pdu, status) => { answers.push(status); return {}; },
|
||
+ log: { ...silentLog, warn: message => { warnings.push(message); } },
|
||
+ onSms: sms => new Promise<void>(resolve => { received.push(sms); releases.push(resolve); }),
|
||
+ });
|
||
|
||
- session.sendReturn = (_pdu, status) => {
|
||
- answers.push(status);
|
||
-
|
||
- return Promise.resolve({});
|
||
- };
|
||
- session.on('sms', sms => { received.push(sms); });
|
||
-
|
||
- for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) {
|
||
+ for (let seqNr = 1; seqNr <= defaults.maxHandledMessages; seqNr++) {
|
||
await incoming.handle(submitPdu(seqNr));
|
||
}
|
||
|
||
- assert.equal(received.length, defaults.maxHeldMessages);
|
||
+ assert.equal(received.length, defaults.maxHandledMessages);
|
||
|
||
- await incoming.handle(submitPdu(defaults.maxHeldMessages + 1));
|
||
+ await incoming.handle(submitPdu(defaults.maxHandledMessages + 1));
|
||
await incoming.handle(segment(7, 1, 2));
|
||
|
||
- assert.equal(received.length, defaults.maxHeldMessages);
|
||
+ assert.equal(received.length, defaults.maxHandledMessages);
|
||
assert.deepEqual(answers, ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']);
|
||
assert.equal(warnings.length, 1, 'reaching the bound warns once, not per refusal');
|
||
|
||
// The refused first segment joined no group, so the second one is taken and completes nothing.
|
||
- await received[0]?.sendResp();
|
||
- await new Promise(resolve => { setImmediate(resolve); });
|
||
+ releases[0]?.();
|
||
+ await settled();
|
||
await incoming.handle(segment(7, 2, 2));
|
||
|
||
assert.equal(answers.at(-1), 'ESME_ROK');
|
||
- assert.equal(received.length, defaults.maxHeldMessages);
|
||
+ assert.equal(received.length, defaults.maxHandledMessages);
|
||
|
||
// A peer keeping its window full crosses the bound on every answer, and that is still one warning.
|
||
- await received[1]?.sendResp();
|
||
- await new Promise(resolve => { setImmediate(resolve); });
|
||
- await incoming.handle(submitPdu(defaults.maxHeldMessages + 2));
|
||
- await incoming.handle(submitPdu(defaults.maxHeldMessages + 3));
|
||
- await incoming.handle(submitPdu(defaults.maxHeldMessages + 4));
|
||
+ releases[1]?.();
|
||
+ await settled();
|
||
+ await incoming.handle(submitPdu(defaults.maxHandledMessages + 2));
|
||
+ await incoming.handle(submitPdu(defaults.maxHandledMessages + 3));
|
||
+ await incoming.handle(submitPdu(defaults.maxHandledMessages + 4));
|
||
|
||
assert.equal(answers.at(-1), 'ESME_RTHROTTLED');
|
||
assert.equal(warnings.length, 1);
|
||
@@ -1666,12 +1682,11 @@ describe('held message bounds', () => {
|
||
|
||
closeAfter(t, session);
|
||
|
||
- const incoming = incomingOn(session);
|
||
+ let received: Sms | undefined;
|
||
+ const incoming = incomingOn(session, { onSms: sms => { received = sms; } });
|
||
const chunk = Buffer.alloc(64 * 1024);
|
||
const carried = submitPdu(1);
|
||
- let received: Sms | undefined;
|
||
|
||
- session.on('sms', sms => { received = sms; });
|
||
await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } });
|
||
|
||
const retained = received?.pduObjs[0]?.params.short_message;
|
||
@@ -1681,35 +1696,78 @@ describe('held message bounds', () => {
|
||
incoming.clear();
|
||
});
|
||
|
||
- test('gives up on a message the application never answers', t => {
|
||
+ test('gives up on a handler that never returns', t => {
|
||
let now = 0;
|
||
- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
|
||
+ const { handled, offer } = handledOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
|
||
|
||
- offer(held, 1);
|
||
+ offer(1);
|
||
now = 61;
|
||
|
||
// The next message sweeps the one that expired, so only the new one is still waited for.
|
||
- offer(held, 2);
|
||
+ offer(2);
|
||
|
||
- assert.equal(held.size, 1);
|
||
+ assert.equal(handled.size, 1);
|
||
|
||
- held.clear();
|
||
+ handled.clear();
|
||
});
|
||
|
||
// Without this the drain sits out its whole budget before returning what a sweep already settled.
|
||
- test('wakes a waiting drain when the last message expires', async t => {
|
||
+ test('wakes a waiting drain when the last handler expires', async t => {
|
||
let now = 0;
|
||
- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
|
||
+ const { handled, offer } = handledOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
|
||
|
||
- offer(held, 1);
|
||
+ offer(1);
|
||
|
||
- const waiting = held.idle(1000, undefined);
|
||
+ const waiting = handled.idle(1000, undefined);
|
||
|
||
now = 61;
|
||
- held.sweep();
|
||
+ handled.sweep();
|
||
|
||
assert.equal(await waiting, 0);
|
||
});
|
||
+
|
||
+ test('answers for a handler that failed before answering, and reports it', async t => {
|
||
+ const session = new Session({ sock: new net.Socket() });
|
||
+ const answers: ErrorName[] = [];
|
||
+ const reported: Error[] = [];
|
||
+ const handled = new HandledMessages({
|
||
+ log: silentLog,
|
||
+ max: 10,
|
||
+ maxOctets: 1_000_000,
|
||
+ onSms: sms => (sms.from === 'answers' ? sms.sendResp() : Promise.reject(new Error('gave up'))),
|
||
+ report: err => { reported.push(err); },
|
||
+ timeout: 10_000,
|
||
+ });
|
||
+ const route: SmsRoute = { ...noRoute, answer: (_pdu, status) => { answers.push(status); return {}; } };
|
||
+
|
||
+ closeAfter(t, session);
|
||
+ handled.offer({ pduObjs: [submitPdu(1)], session }, route, 'ESME_RX_T_APPN');
|
||
+ handled.offer({ pduObjs: [{ ...submitPdu(2), params: { ...submitPdu(2).params, source_addr: 'answers' } }], session }, route, 'ESME_RX_T_APPN');
|
||
+ await settled();
|
||
+
|
||
+ assert.deepEqual(answers.sort(), ['ESME_ROK', 'ESME_RX_T_APPN']);
|
||
+ assert.deepEqual(reported.map(err => err.message), ['gave up']);
|
||
+ assert.equal(handled.size, 0, 'a failed handler holds nothing');
|
||
+ });
|
||
+
|
||
+ test('answers for a message no handler takes', async t => {
|
||
+ const session = new Session({ sock: new net.Socket() });
|
||
+ const answers: ErrorName[] = [];
|
||
+ const handled = new HandledMessages({
|
||
+ log: silentLog,
|
||
+ max: 10,
|
||
+ maxOctets: 1_000_000,
|
||
+ onSms: undefined,
|
||
+ report: () => undefined,
|
||
+ timeout: 10_000,
|
||
+ });
|
||
+
|
||
+ closeAfter(t, session);
|
||
+ handled.offer({ pduObjs: [submitPdu(1)], session }, { ...noRoute, answer: (_pdu, status) => { answers.push(status); return {}; } }, 'ESME_RTHROTTLED');
|
||
+ await settled();
|
||
+
|
||
+ assert.deepEqual(answers, ['ESME_RTHROTTLED']);
|
||
+ });
|
||
});
|
||
|
||
describe('sendResp()', () => {
|
||
@@ -1720,21 +1778,49 @@ describe('sendResp()', () => {
|
||
closeAfter(t, session);
|
||
|
||
let answered = 0;
|
||
-
|
||
- session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') });
|
||
-
|
||
const sms = createSms({
|
||
pduObjs: [submitPdu(1)],
|
||
session,
|
||
}, {
|
||
+ ...noRoute,
|
||
+ answer: () => ({ err: new Error('Socket is closed') }),
|
||
answered: () => { answered++; },
|
||
- lostLink: () => false,
|
||
- send: () => Promise.resolve({ err: new Error('never sent') }),
|
||
});
|
||
|
||
assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/);
|
||
assert.equal(answered, 0);
|
||
});
|
||
+
|
||
+ test('answers once, and keeps the id it was given only once that answer is on the wire', async t => {
|
||
+ const session = new Session({ sock: new net.Socket() });
|
||
+
|
||
+ closeAfter(t, session);
|
||
+
|
||
+ const answers: string[] = [];
|
||
+ let writable = false;
|
||
+ const sms = createSms({ pduObjs: [submitPdu(1)], session }, {
|
||
+ ...noRoute,
|
||
+ answer: (_pdu, _status, params) => {
|
||
+ if (!writable) return { err: new Error('Socket is closed') };
|
||
+
|
||
+ answers.push(paramText(params.message_id));
|
||
+
|
||
+ return {};
|
||
+ },
|
||
+ answered: () => undefined,
|
||
+ });
|
||
+ const generated = sms.smsId;
|
||
+
|
||
+ assert.ok((await sms.sendResp({ smsId: 'never-written' })).err);
|
||
+ assert.equal(sms.smsId, generated, 'a failed answer does not rename the message');
|
||
+
|
||
+ writable = true;
|
||
+
|
||
+ assert.deepEqual(await sms.sendResp({ smsId: 'written' }), {});
|
||
+ assert.equal(sms.smsId, 'written');
|
||
+ assert.match((await sms.sendResp()).err?.message ?? '', /already answered/);
|
||
+ assert.deepEqual(answers, ['written']);
|
||
+ });
|
||
});
|
||
|
||
describe('sendDlr()', () => {
|
||
@@ -1749,8 +1835,8 @@ describe('sendDlr()', () => {
|
||
pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)],
|
||
session,
|
||
}, {
|
||
+ ...noRoute,
|
||
answered: () => undefined,
|
||
- lostLink: () => false,
|
||
send: () => {
|
||
call++;
|
||
|
||
@@ -2351,7 +2437,7 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
params: submitSmParams(
|
||
{ from: '46701113311', message: text, to: '46709771337' },
|
||
segment,
|
||
- { encoding: 'ASCII', multipart: true },
|
||
+ { encoding: 'GSM', multipart: true },
|
||
),
|
||
});
|
||
|
||
@@ -2372,20 +2458,20 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
}
|
||
|
||
test('gets every segment answered as it arrives, and the application one whole message', async t => {
|
||
- const smpp = await startServer(t);
|
||
const messages: Sms[] = [];
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', sms => {
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: sms => {
|
||
messages.push(sms);
|
||
- resolve(sms);
|
||
- }));
|
||
+ incoming.onSms(sms);
|
||
+ },
|
||
});
|
||
const { session } = await connect(t, smpp, { responseTimeout: 1000 });
|
||
|
||
assert.ok(session);
|
||
|
||
const answers = await submitSerially(session, 0x2A);
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
|
||
assert.equal(sms.message, text);
|
||
assert.equal(sms.pduObjs.length, answers.length);
|
||
@@ -2401,10 +2487,8 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
|
||
// The documented single-segment contract, which the segment-by-segment answer must not touch.
|
||
test('answers a single-segment message only once the application does, with the id it chose', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
@@ -2417,7 +2501,7 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
source_addr: '46701113311',
|
||
},
|
||
});
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
|
||
assert.equal(await within(150, submitted), undefined, 'nothing may answer for the application');
|
||
assert.equal(sms.answeredOnArrival, false);
|
||
@@ -2433,17 +2517,15 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
});
|
||
|
||
test('refuses an id and a refusing status for segments already on the wire', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp, { responseTimeout: 1000 });
|
||
|
||
assert.ok(session);
|
||
|
||
await submitSerially(session, 0x2B);
|
||
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
const named = await sms.sendResp({ smsId: '0199e0ea-1f3d-7ab4-8c21-6d4e5f0a9b73' });
|
||
const refused = await sms.sendResp({ status: 'ESME_RMSGQFUL' });
|
||
|
||
@@ -2454,13 +2536,10 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
});
|
||
|
||
test('reports a half-arrived message it has already answered, and holds nothing after', async t => {
|
||
- const smpp = await startServer(t, { reassemblyTimeout: 60 });
|
||
const messages: Sms[] = [];
|
||
+ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); }, reassemblyTimeout: 60 });
|
||
const lost = once<Error>(resolve => {
|
||
- smpp.on('session', bound => {
|
||
- bound.on('sessionError', resolve);
|
||
- bound.on('sms', sms => { messages.push(sms); });
|
||
- });
|
||
+ smpp.on('session', bound => { bound.on('sessionError', resolve); });
|
||
});
|
||
const { session } = await connect(t, smpp, { responseTimeout: 1000 });
|
||
|
||
@@ -2500,7 +2579,7 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
params: submitSmParams(
|
||
{ from: '46701113311', message: text, to: '46709771337' },
|
||
first,
|
||
- { encoding: 'ASCII', multipart: true },
|
||
+ { encoding: 'GSM', multipart: true },
|
||
),
|
||
});
|
||
|
||
@@ -2512,29 +2591,32 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
});
|
||
|
||
test('close() still waits for a concatenated message the application has not answered', async t => {
|
||
- const smpp = await startServer(t, { shutdownTimeout: 50 });
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', resolve));
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: sms => {
|
||
+ incoming.onSms(sms);
|
||
+
|
||
+ return stuck(sms);
|
||
+ },
|
||
+ shutdownTimeout: 50,
|
||
});
|
||
const { session } = await connect(t, smpp, { responseTimeout: 1000 });
|
||
|
||
assert.ok(session);
|
||
|
||
await submitSerially(session, 0x2D);
|
||
- await incoming;
|
||
+ await incoming.sms;
|
||
|
||
const closed = await peerOf(smpp).close();
|
||
|
||
assert.ok(closed.err instanceof Error);
|
||
- assert.match(closed.err.message, /1 message\(s\) unanswered/);
|
||
+ assert.match(closed.err.message, /1 message\(s\) still being handled/);
|
||
});
|
||
|
||
// pduObjs.length is 1 either way here, so answeredOnArrival is the only thing that can say.
|
||
test('marks a one-part concatenated message answered, as its segment count cannot', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp, { responseTimeout: 1000 });
|
||
|
||
assert.ok(session);
|
||
@@ -2551,7 +2633,7 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
source_addr: '46701113311',
|
||
},
|
||
});
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
|
||
assert.equal(answered.err, undefined);
|
||
assert.ok(answered.pduObj);
|
||
@@ -2564,10 +2646,8 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
|
||
// esm_class said there was a UDH, and there is no group its concatenation fields can join.
|
||
test('answers a segment whose UDH cannot be honoured rather than leaving the peer waiting', async t => {
|
||
- const smpp = await startServer(t);
|
||
const messages: Sms[] = [];
|
||
-
|
||
- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); }));
|
||
+ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } });
|
||
|
||
const { session } = await connect(t, smpp, { responseTimeout: 1000 });
|
||
|
||
@@ -2594,10 +2674,8 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||
|
||
// Its esm_class is 0x00 and correct, so the refusal names the optional parameters instead.
|
||
test('refuses a sar_* segment the TLVs number impossibly by naming those TLVs', async t => {
|
||
- const smpp = await startServer(t);
|
||
const messages: Sms[] = [];
|
||
-
|
||
- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); }));
|
||
+ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } });
|
||
|
||
const { session } = await connect(t, smpp, { responseTimeout: 1000 });
|
||
|
||
@@ -2662,13 +2740,11 @@ describe('AbortSignal on a send', () => {
|
||
t: TestContext,
|
||
options: Parameters<typeof client>[0] = {},
|
||
): Promise<Session> {
|
||
- const smpp = await startServer(t);
|
||
+ // The peer takes the message and never answers it, so the one slot stays held.
|
||
+ const smpp = await startServer(t, { onSms: () => undefined });
|
||
const onWire = once<PduObject>(resolve => {
|
||
smpp.on('session', bound => bound.on('incomingPduObj', resolve));
|
||
});
|
||
-
|
||
- smpp.on('session', bound => bound.on('sms', () => undefined));
|
||
-
|
||
const { session } = await connect(t, smpp, { maxOutstanding: 1, ...options });
|
||
|
||
assert.ok(session);
|
||
@@ -2716,24 +2792,23 @@ describe('AbortSignal on a send', () => {
|
||
});
|
||
|
||
test('leaves the freed slot to the next send rather than to the waiter that gave up', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const holding = once<Sms>(resolve => { smpp.on('session', bound => bound.on('sms', resolve)); });
|
||
+ const holding = inbox();
|
||
let firstTaken = false;
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: async sms => {
|
||
+ holding.onSms(sms);
|
||
|
||
- smpp.on('session', bound => {
|
||
- bound.on('sms', async sms => {
|
||
if (firstTaken) await sms.sendResp();
|
||
|
||
firstTaken = true;
|
||
- });
|
||
+ },
|
||
});
|
||
-
|
||
const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 });
|
||
|
||
assert.ok(session);
|
||
void session.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' });
|
||
|
||
- const held = await holding;
|
||
+ const held = await holding.sms;
|
||
const controller = new AbortController();
|
||
const abandoned = session.sendSms(
|
||
{ from: '46701113311', message: 'abandoned in the queue', to: '46709771337' },
|
||
@@ -2762,15 +2837,22 @@ describe('AbortSignal on a send', () => {
|
||
});
|
||
|
||
describe('graceful shutdown', () => {
|
||
+ /** A message the server's handler holds until `release()`, its submit still unanswered. */
|
||
async function submitInFlight(
|
||
t: TestContext,
|
||
options: Parameters<typeof client>[0] = {},
|
||
serverOptions: Parameters<typeof server>[0] = {},
|
||
message = 'answer me',
|
||
) {
|
||
- const smpp = await startServer(t, serverOptions);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', resolve));
|
||
+ const incoming = inbox();
|
||
+ const held = holding();
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: sms => {
|
||
+ incoming.onSms(sms);
|
||
+
|
||
+ return held.onSms(sms);
|
||
+ },
|
||
+ ...serverOptions,
|
||
});
|
||
const { session } = await connect(t, smpp, options);
|
||
|
||
@@ -2778,7 +2860,7 @@ describe('graceful shutdown', () => {
|
||
|
||
const sent = session.sendSms({ from: '46701113311', message, to: '46709771337' });
|
||
|
||
- return { sent, session, sms: await incoming, smpp };
|
||
+ return { release: held.release, sent, session, sms: await incoming.sms, smpp };
|
||
}
|
||
|
||
test('close() waits out a submit already on the wire and refuses new ones', async t => {
|
||
@@ -2824,43 +2906,76 @@ describe('graceful shutdown', () => {
|
||
assert.deepEqual(await unbound, {});
|
||
});
|
||
|
||
- test('close() waits for a message the application has not answered yet', async t => {
|
||
- const { sent, smpp, sms } = await submitInFlight(t);
|
||
+ test('close() waits for a handler that has not returned yet', async t => {
|
||
+ const { release, sent, smpp, sms } = await submitInFlight(t);
|
||
const closing = peerOf(smpp).close();
|
||
|
||
await delay(50);
|
||
await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' });
|
||
+ release();
|
||
|
||
assert.deepEqual(await closing, {});
|
||
assert.deepEqual((await sent).smsIds, ['answered-during-the-inbound-drain']);
|
||
});
|
||
|
||
- test('gives up on a message the application never answers', async t => {
|
||
+ // The handler's return is what the drain waits for, whether or not the message was answered.
|
||
+ test('close() keeps waiting for a handler that answered and is still working', async t => {
|
||
+ const { release, sent, smpp, sms } = await submitInFlight(t);
|
||
+
|
||
+ await sms.sendResp({ smsId: 'answered-before-the-drain' });
|
||
+
|
||
+ const closing = peerOf(smpp).close();
|
||
+ let closed = false;
|
||
+
|
||
+ void closing.then(() => { closed = true; });
|
||
+ await delay(50);
|
||
+
|
||
+ assert.equal(closed, false, 'the answer went out, and the handler is still working');
|
||
+ release();
|
||
+
|
||
+ assert.deepEqual(await closing, {});
|
||
+ assert.deepEqual((await sent).smsIds, ['answered-before-the-drain']);
|
||
+ });
|
||
+
|
||
+ test('close() stops waiting once the handler returns, answered or not', async t => {
|
||
+ const { release, sent, smpp } = await submitInFlight(t, { responseTimeout: 200 }, { shutdownTimeout: 30_000 });
|
||
+
|
||
+ release();
|
||
+
|
||
+ const started = Date.now();
|
||
+
|
||
+ assert.deepEqual(await peerOf(smpp).close(), {});
|
||
+ assert.ok(Date.now() - started < 1000);
|
||
+ assert.ok((await sent).err instanceof Error, 'a handler that returned without answering left the peer waiting');
|
||
+ });
|
||
+
|
||
+ test('gives up on a handler that never returns', async t => {
|
||
const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 });
|
||
const closed = await peerOf(smpp).close();
|
||
|
||
assert.ok(closed.err instanceof Error);
|
||
- assert.match(closed.err.message, /1 message\(s\) unanswered/);
|
||
+ assert.match(closed.err.message, /1 message\(s\) still being handled/);
|
||
});
|
||
|
||
- // Waiting forever is safe for the peer, which every request times out on. The application is not.
|
||
- test('falls back to responseTimeout for a held message when the shutdown waits forever', async t => {
|
||
- const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 });
|
||
+ test('waits out a handler for as long as it takes at shutdownTimeout 0', async t => {
|
||
+ const { release, sent, smpp, sms } = await submitInFlight(t, {}, { shutdownTimeout: 0 });
|
||
const started = Date.now();
|
||
- const closed = await peerOf(smpp).close();
|
||
- const waited = Date.now() - started;
|
||
+ const closing = peerOf(smpp).close();
|
||
|
||
- assert.ok(closed.err instanceof Error);
|
||
- assert.match(closed.err.message, /1 message\(s\) unanswered/);
|
||
- assert.ok(waited >= 190, `waited ${String(waited)} ms, so the fallback was not what bounded it`);
|
||
- assert.ok(waited < 2000);
|
||
+ await delay(200);
|
||
+ await sms.sendResp({ smsId: 'answered-late' });
|
||
+ release();
|
||
+
|
||
+ assert.deepEqual(await closing, {});
|
||
+ assert.ok(Date.now() - started >= 190);
|
||
+ assert.deepEqual((await sent).smsIds, ['answered-late']);
|
||
});
|
||
|
||
// leftOf() floors what is left at 1 ms: at 0 the request half would read "wait forever" instead.
|
||
- test('still ends when the message half has spent the whole shutdown budget', async t => {
|
||
- const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 100 });
|
||
+ test('still ends when the handler half has spent the whole shutdown budget', async t => {
|
||
+ // The client's handler never returns, so its answer never comes and the request stays in the window.
|
||
+ const { smpp } = await submitInFlight(t, { onSms: stuck }, { shutdownTimeout: 100 });
|
||
const bound = peerOf(smpp);
|
||
- // The client listens for no 'sms', so this one is never answered and stays in the window.
|
||
const unanswered = bound.send({
|
||
cmdName: 'submit_sm',
|
||
params: {
|
||
@@ -2876,14 +2991,13 @@ describe('graceful shutdown', () => {
|
||
}),
|
||
]);
|
||
|
||
- assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/);
|
||
+ assert.match(closed.err?.message ?? '', /1 message\(s\) still being handled; .*1 request\(s\) unfinished/);
|
||
assert.ok((await unanswered).err instanceof Error);
|
||
});
|
||
|
||
- // The README's own listener answers and then sends its receipt, one turn later. Multipart, because
|
||
- // a receipt sent one-after-a-response outruns that turn on every segment past the first.
|
||
- test('a receipt sent right after the response still goes out mid-drain', async t => {
|
||
- const { sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400));
|
||
+ // The README's own handler answers and then sends its receipt; both go out inside the handler.
|
||
+ test('a receipt sent after the response still goes out mid-drain', async t => {
|
||
+ const { release, sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400));
|
||
const received: Dlr[] = [];
|
||
const receipts = once<Dlr[]>(resolve => {
|
||
session.on('dlr', dlr => {
|
||
@@ -2901,11 +3015,12 @@ describe('graceful shutdown', () => {
|
||
|
||
assert.equal(receiptSent.err, undefined);
|
||
assert.deepEqual((await receipts).map(dlr => dlr.smsId), ids);
|
||
+ release();
|
||
assert.deepEqual(await closing, {});
|
||
assert.deepEqual((await sent).smsIds, ids);
|
||
});
|
||
|
||
- test('a message no listener took does not hold the shutdown up', async t => {
|
||
+ test('a message no handler takes is refused at once and does not hold the shutdown up', async t => {
|
||
const smpp = await startServer(t, { shutdownTimeout: 30_000 });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
@@ -2924,23 +3039,22 @@ describe('graceful shutdown', () => {
|
||
});
|
||
|
||
await arrived;
|
||
- await delay(50);
|
||
|
||
+ const refused = await sent;
|
||
const started = Date.now();
|
||
|
||
+ assert.match(refused.err?.message ?? '', /ESME_RTHROTTLED/, 'the peer is told to retry, not left waiting');
|
||
assert.deepEqual(await bound.close(), {});
|
||
assert.ok(Date.now() - started < 1000);
|
||
- assert.ok((await sent).err instanceof Error);
|
||
});
|
||
|
||
- // emit() releases the hold of a listener that throws; one that rejects may cost no more than that.
|
||
- test('a listener that rejected before answering does not hold the shutdown up', async t => {
|
||
- const smpp = await startServer(t, { shutdownTimeout: 30_000 });
|
||
+ test('a handler that rejected before answering has the message refused for it, and holds nothing', async t => {
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: () => Promise.reject(new Error('the handler gave up')),
|
||
+ shutdownTimeout: 30_000,
|
||
+ });
|
||
const failed = once<Error>(resolve => {
|
||
- smpp.on('session', bound => {
|
||
- bound.on('sessionError', resolve);
|
||
- bound.on('sms', () => Promise.reject(new Error('the listener gave up')));
|
||
- });
|
||
+ smpp.on('session', bound => { bound.on('sessionError', resolve); });
|
||
});
|
||
const { session } = await connect(t, smpp);
|
||
|
||
@@ -2948,55 +3062,50 @@ describe('graceful shutdown', () => {
|
||
|
||
const sent = session.sendSms({
|
||
from: '46701113311',
|
||
- message: 'the listener rejects',
|
||
+ message: 'the handler rejects',
|
||
to: '46709771337',
|
||
});
|
||
|
||
- assert.equal((await failed).message, 'the listener gave up');
|
||
+ assert.equal((await failed).message, 'the handler gave up');
|
||
|
||
+ const refused = await sent;
|
||
const started = Date.now();
|
||
|
||
+ assert.match(refused.err?.message ?? '', /ESME_RTHROTTLED/);
|
||
assert.deepEqual(await peerOf(smpp).close(), {});
|
||
assert.ok(Date.now() - started < 1000);
|
||
- assert.ok((await sent).err instanceof Error);
|
||
});
|
||
|
||
- test('waits for the listener still working when another one rejected', async t => {
|
||
- const smpp = await startServer(t, { shutdownTimeout: 30_000 });
|
||
+ test('a handler that rejected after answering leaves that answer alone', async t => {
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: async sms => {
|
||
+ await sms.sendResp({ smsId: 'answered-then-failed' });
|
||
+
|
||
+ throw new Error('failed after answering');
|
||
+ },
|
||
+ });
|
||
const failed = once<Error>(resolve => {
|
||
- smpp.on('session', bound => {
|
||
- bound.on('sessionError', resolve);
|
||
- bound.on('sms', async sms => {
|
||
- await delay(100);
|
||
- await sms.sendResp({ smsId: 'answered-after-the-other-gave-up' });
|
||
- });
|
||
- bound.on('sms', () => Promise.reject(new Error('the audit listener gave up')));
|
||
- });
|
||
+ smpp.on('session', bound => { bound.on('sessionError', resolve); });
|
||
});
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
|
||
- const sent = session.sendSms({
|
||
- from: '46701113311',
|
||
- message: 'two listeners, one gives up',
|
||
- to: '46709771337',
|
||
- });
|
||
+ const sent = await session.sendSms({ from: '46701113311', message: 'answered first', to: '46709771337' });
|
||
|
||
- assert.equal((await failed).message, 'the audit listener gave up');
|
||
- assert.deepEqual(await peerOf(smpp).close(), {});
|
||
- assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']);
|
||
+ assert.equal((await failed).message, 'failed after answering');
|
||
+ assert.deepEqual(sent.smsIds, ['answered-then-failed']);
|
||
});
|
||
|
||
- // Nothing reached the peer, so a drain counting this answered would report an outcome that never was.
|
||
- test('leaves a message the library refused to answer unanswered', async t => {
|
||
+ // A refused answer changes nothing: the handler is still running, so the drain still waits on it.
|
||
+ test('keeps waiting on a handler whose answer the library refused', async t => {
|
||
const { sms, smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 });
|
||
const refused = await sms.sendResp({ smsId: '' });
|
||
const closed = await peerOf(smpp).close();
|
||
|
||
assert.match(refused.err?.message ?? '', /smsId must not be empty/);
|
||
assert.ok(closed.err instanceof Error);
|
||
- assert.match(closed.err.message, /1 message\(s\) unanswered/);
|
||
+ assert.match(closed.err.message, /1 message\(s\) still being handled/);
|
||
});
|
||
|
||
test('gives up on a request that outlasts shutdownTimeout', async t => {
|
||
@@ -3031,8 +3140,8 @@ describe('graceful shutdown', () => {
|
||
|
||
// The queued segments are the whole reason the drain waits on the window and not on the pending map.
|
||
test('counts the segments still queued behind a full window', async t => {
|
||
- // No 'sms' listener, so the single-segment message holding the only slot is never answered.
|
||
- const smpp = await startServer(t);
|
||
+ // The handler never returns nor answers, so the single-segment message holds the only slot.
|
||
+ const smpp = await startServer(t, { onSms: stuck });
|
||
const onWire = once<PduObject>(resolve => {
|
||
smpp.on('session', bound => bound.on('incomingPduObj', resolve));
|
||
});
|
||
@@ -3208,12 +3317,7 @@ describe('message id notation', () => {
|
||
}
|
||
|
||
test('correlates a hex submit_sm_resp against a decimal receipt', async t => {
|
||
- const smpp = await startServer(t);
|
||
-
|
||
- smpp.on('session', bound => {
|
||
- bound.on('sms', sms => { void sms.sendResp({ smsId: '1a2b' }); });
|
||
- });
|
||
-
|
||
+ const smpp = await startServer(t, { onSms: sms => sms.sendResp({ smsId: '1a2b' }) });
|
||
const { session } = await connect(t, smpp, {
|
||
smsIdFormat: { receipt: 'decimal', submitResp: 'hex' },
|
||
});
|
||
@@ -3238,12 +3342,13 @@ describe('message id notation', () => {
|
||
});
|
||
|
||
test('leaves the segment ids of a multipart send to merge as they are', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', sms => {
|
||
- resolve(sms);
|
||
- void sms.sendResp();
|
||
- }));
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: sms => {
|
||
+ incoming.onSms(sms);
|
||
+
|
||
+ return sms.sendResp();
|
||
+ },
|
||
});
|
||
const { session } = await connect(t, smpp, {
|
||
smsIdFormat: { receipt: 'decimal', submitResp: 'hex' },
|
||
@@ -3253,7 +3358,7 @@ describe('message id notation', () => {
|
||
|
||
const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
|
||
const sent = await sendOne(session, 'x'.repeat(200));
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
|
||
assert.deepEqual(sent.smsIds, [1, 2].map(part => `${sms.smsId}-${String(part)}`));
|
||
|
||
diff --git a/test/session.test.ts b/test/session.test.ts
|
||
index 1f31f0b..32e1e0b 100644
|
||
--- a/test/session.test.ts
|
||
+++ b/test/session.test.ts
|
||
@@ -74,6 +74,16 @@ function raceWithin<T>(ms: number, promise: Promise<T>): Promise<T | false> {
|
||
return Promise.race([promise, delay(ms).then((): false => false)]);
|
||
}
|
||
|
||
+type Inbox = { onSms: (sms: Sms) => void; sms: Promise<Sms> };
|
||
+
|
||
+/** Hands the test the first message and returns at once, so nothing is held. */
|
||
+function inbox(): Inbox {
|
||
+ let deliver: (sms: Sms) => void = () => undefined;
|
||
+ const sms = once<Sms>(resolve => { deliver = resolve; });
|
||
+
|
||
+ return { onSms: message => { deliver(message); }, sms };
|
||
+}
|
||
+
|
||
/** A socket read as a queue of parsed PDUs; `handled` takes the ones the peer answers itself. */
|
||
function pduQueue(
|
||
sock: net.Socket,
|
||
@@ -507,10 +517,8 @@ describe('bind direction', () => {
|
||
});
|
||
|
||
test('refuses sendSms() on a receiver-bound session before it reaches the wire', async t => {
|
||
- const smpp = await startServer(t);
|
||
const arrived: Sms[] = [];
|
||
-
|
||
- smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms)));
|
||
+ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms); } });
|
||
|
||
const { session } = await connect(t, smpp, { bindType: 'receiver' });
|
||
|
||
@@ -542,16 +550,14 @@ describe('bind direction', () => {
|
||
});
|
||
|
||
test('refuses sendDlr() to a transmitter-bound peer before it reaches the wire', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', peer => peer.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
||
|
||
assert.ok(session);
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -585,9 +591,7 @@ describe('bind direction', () => {
|
||
});
|
||
|
||
test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => {
|
||
- const smpp = await startServer(t);
|
||
-
|
||
- smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp()));
|
||
+ const smpp = await startServer(t, { onSms: sms => sms.sendResp() });
|
||
|
||
const receiving = await connect(t, smpp, { bindType: 'receiver' });
|
||
|
||
@@ -619,16 +623,14 @@ describe('bind direction', () => {
|
||
|
||
describe('sending', () => {
|
||
test('delivers a simple SMS with the sender TON derived from the address', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
|
||
const [sms, sent] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
const refused = await received.sendResp({ smsId: '' });
|
||
|
||
assert.ok(refused.err instanceof Error);
|
||
@@ -656,17 +658,15 @@ describe('sending', () => {
|
||
});
|
||
|
||
test('reassembles a long SMS and answers every segment', async t => {
|
||
- const smpp = await startServer(t);
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const message = 'Lorem ipsum dolor sit amet, '.repeat(20);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
|
||
const [sms, sent] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -682,17 +682,15 @@ describe('sending', () => {
|
||
});
|
||
|
||
test('carries a UCS2 message through unchanged', async t => {
|
||
- const smpp = await startServer(t);
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const message = 'räksmörgås تست 一';
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -705,16 +703,14 @@ describe('sending', () => {
|
||
});
|
||
|
||
test('marks a flash message without losing the UCS2 alphabet', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -729,16 +725,14 @@ describe('sending', () => {
|
||
});
|
||
|
||
test('puts the address TON and NPI the caller chose on the wire', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', peer => peer.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -768,11 +762,12 @@ describe('receiving', () => {
|
||
async function inbound(
|
||
t: TestContext,
|
||
options: ServerOptions = {},
|
||
+ clientOptions: Parameters<typeof client>[0] = {},
|
||
): Promise<{ peer: Session; session: Session }> {
|
||
const smpp = await startServer(t, options);
|
||
|
||
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
|
||
- const { session } = await connect(t, smpp);
|
||
+ const { session } = await connect(t, smpp, clientOptions);
|
||
|
||
assert.ok(session);
|
||
|
||
@@ -780,8 +775,8 @@ describe('receiving', () => {
|
||
}
|
||
|
||
test('hands a client a deliver_sm that is not a delivery receipt', async t => {
|
||
- const { peer, session } = await inbound(t);
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
+ const incoming = inbox();
|
||
+ const { peer } = await inbound(t, {}, { onSms: incoming.onSms });
|
||
const delivered = peer.send({
|
||
cmdName: 'deliver_sm',
|
||
params: {
|
||
@@ -790,7 +785,7 @@ describe('receiving', () => {
|
||
source_addr: '46701113311',
|
||
},
|
||
});
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'a deliver_sm that carries no receipt is an inbound SMS');
|
||
assert.equal(sms.from, '46701113311');
|
||
@@ -813,8 +808,8 @@ describe('receiving', () => {
|
||
|
||
// SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty.
|
||
test('reads an inbound message the peer carried in message_payload', async t => {
|
||
- const { peer, session } = await inbound(t);
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
+ const incoming = inbox();
|
||
+ const { peer } = await inbound(t, {}, { onSms: incoming.onSms });
|
||
const text = 'the whole body, carried in the TLV';
|
||
const delivered = peer.send({
|
||
cmdName: 'deliver_sm',
|
||
@@ -825,7 +820,7 @@ describe('receiving', () => {
|
||
},
|
||
tlvs: { message_payload: { tagValue: Buffer.from(text, 'latin1') } },
|
||
});
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'a body the peer put in message_payload is still a message');
|
||
assert.equal(sms.message, text);
|
||
@@ -841,15 +836,15 @@ describe('receiving', () => {
|
||
});
|
||
|
||
test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => {
|
||
- const { peer, session } = await inbound(t);
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
+ const incoming = inbox();
|
||
+ const { peer } = await inbound(t, {}, { onSms: incoming.onSms });
|
||
const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806';
|
||
const delivered = peer.send({
|
||
cmdName: 'data_sm',
|
||
params: { destination_addr: '46709771337', source_addr: '46701113311' },
|
||
tlvs: { message_payload: { tagValue: Buffer.from('a message carried on the data command', 'latin1') } },
|
||
});
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'data_sm is a peer of deliver_sm, not a command to refuse');
|
||
assert.equal(sms.message, 'a message carried on the data command');
|
||
@@ -867,12 +862,10 @@ describe('receiving', () => {
|
||
});
|
||
|
||
test('hands a client a receipt carried on data_sm as a dlr', async t => {
|
||
- const { peer, session } = await inbound(t);
|
||
+ let messages = 0;
|
||
+ const { peer, session } = await inbound(t, {}, { onSms: () => { messages++; } });
|
||
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
|
||
const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77';
|
||
- let messages = 0;
|
||
-
|
||
- session.on('sms', () => { messages++; });
|
||
|
||
const delivered = peer.send({
|
||
cmdName: 'data_sm',
|
||
@@ -903,19 +896,19 @@ describe('receiving', () => {
|
||
|
||
// At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends.
|
||
test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => {
|
||
- const smpp = await startServer(t);
|
||
const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:';
|
||
const messages: Sms[] = [];
|
||
const reports: Dlr[] = [];
|
||
-
|
||
- smpp.on('session', peer => {
|
||
- peer.on('dlr', dlr => { reports.push(dlr); });
|
||
- peer.on('sms', sms => {
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: sms => {
|
||
messages.push(sms);
|
||
- void sms.sendResp();
|
||
- });
|
||
+
|
||
+ return sms.sendResp();
|
||
+ },
|
||
});
|
||
|
||
+ smpp.on('session', peer => { peer.on('dlr', dlr => { reports.push(dlr); }); });
|
||
+
|
||
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
||
|
||
assert.ok(session);
|
||
@@ -965,8 +958,8 @@ describe('receiving', () => {
|
||
});
|
||
|
||
test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
|
||
- const { peer, session } = await inbound(t);
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
+ const incoming = inbox();
|
||
+ const { peer } = await inbound(t, {}, { onSms: incoming.onSms });
|
||
const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6);
|
||
const segments = splitMessage(text, { reference: 0x3B });
|
||
|
||
@@ -990,7 +983,7 @@ describe('receiving', () => {
|
||
answers.push(sent.pduObj);
|
||
}
|
||
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'the segments join into one message wherever their bodies were carried');
|
||
assert.equal(sms.message, text);
|
||
@@ -1005,11 +998,9 @@ describe('receiving', () => {
|
||
});
|
||
|
||
test('hands a client a report as a dlr rather than as an sms', async t => {
|
||
- const { peer, session } = await inbound(t);
|
||
- const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
|
||
let messages = 0;
|
||
-
|
||
- session.on('sms', () => { messages++; });
|
||
+ const { peer, session } = await inbound(t, {}, { onSms: () => { messages++; } });
|
||
+ const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
|
||
|
||
const delivered = peer.send({
|
||
cmdName: 'deliver_sm',
|
||
@@ -1085,8 +1076,8 @@ describe('receiving', () => {
|
||
|
||
test('reassembles a multipart inbound SMS before the sms event', async t => {
|
||
const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6);
|
||
- const { peer, session } = await inbound(t);
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
+ const incoming = inbox();
|
||
+ const { peer } = await inbound(t, {}, { onSms: incoming.onSms });
|
||
const segments = splitMessage(message, { reference: 42 });
|
||
|
||
assert.equal(segments.length, 2);
|
||
@@ -1100,7 +1091,7 @@ describe('receiving', () => {
|
||
source_addr: '46701113311',
|
||
},
|
||
})));
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'both segments should reassemble into one message');
|
||
assert.equal(sms.message, message);
|
||
@@ -1124,8 +1115,8 @@ describe('receiving', () => {
|
||
}
|
||
|
||
test('answers every sar_* segment on arrival and hands the application one message', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 });
|
||
|
||
assert.ok(session);
|
||
@@ -1149,7 +1140,7 @@ describe('receiving', () => {
|
||
answers.push(sent.pduObj);
|
||
}
|
||
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message');
|
||
assert.equal(sms.message, parts.join(''));
|
||
@@ -1161,8 +1152,8 @@ describe('receiving', () => {
|
||
});
|
||
|
||
test('joins sar_* segments in the order they number themselves', async t => {
|
||
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
+ const incoming = inbox();
|
||
+ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: incoming.onSms });
|
||
const parts = ['first ', 'second ', 'third'];
|
||
|
||
for (const index of [2, 0, 1]) {
|
||
@@ -1180,15 +1171,15 @@ describe('receiving', () => {
|
||
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||
}
|
||
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'the segments number themselves, so the order they arrive in is not the message');
|
||
assert.equal(sms.message, parts.join(''));
|
||
});
|
||
|
||
test('reassembles a sar_* segment whose body is in message_payload', async t => {
|
||
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
+ const incoming = inbox();
|
||
+ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: incoming.onSms });
|
||
const parts = ['in the mandatory field, ', 'and in the TLV'];
|
||
|
||
for (const [index, part] of parts.entries()) {
|
||
@@ -1214,7 +1205,7 @@ describe('receiving', () => {
|
||
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||
}
|
||
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'a segment carries its body where any other message may carry one');
|
||
assert.equal(sms.message, parts.join(''));
|
||
@@ -1223,10 +1214,8 @@ describe('receiving', () => {
|
||
|
||
// The UDH reference is 8 bits and sar_msg_ref_num is 16, so the same number is two messages.
|
||
test('keeps a UDH group and a sar_* group sharing a reference apart', async t => {
|
||
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||
const messages: Sms[] = [];
|
||
-
|
||
- session.on('sms', sms => { messages.push(sms); });
|
||
+ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: sms => { messages.push(sms); } });
|
||
|
||
const udhText = 'the message numbered by its user data header. '.repeat(5);
|
||
const udhSegments = splitMessage(udhText, { reference: 5 });
|
||
@@ -1271,8 +1260,8 @@ describe('receiving', () => {
|
||
|
||
// Nothing compares the two references: each spelling counts in a space of its own.
|
||
test('groups a segment carrying both spellings by its UDH', async t => {
|
||
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||
- const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||
+ const incoming = inbox();
|
||
+ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: incoming.onSms });
|
||
const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4);
|
||
const segments = splitMessage(message, { reference: 7 });
|
||
|
||
@@ -1294,19 +1283,18 @@ describe('receiving', () => {
|
||
assert.ok(sent.pduObj);
|
||
}
|
||
|
||
- const sms = await raceWithin(2000, incoming);
|
||
+ const sms = await raceWithin(2000, incoming.sms);
|
||
|
||
assert.ok(sms, 'the UDH says the message is whole at two segments, and the UDH is what is read');
|
||
assert.equal(sms.message, message);
|
||
});
|
||
|
||
test('reads a receipt carrying sar_* fields as a dlr, never as a segment', async t => {
|
||
- const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||
const reports: Dlr[] = [];
|
||
let messages = 0;
|
||
+ const { peer, session } = await inbound(t, { responseTimeout: 1000 }, { onSms: () => { messages++; } });
|
||
|
||
session.on('dlr', dlr => { reports.push(dlr); });
|
||
- session.on('sms', () => { messages++; });
|
||
|
||
const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63';
|
||
const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b';
|
||
@@ -1350,10 +1338,8 @@ describe('receiving', () => {
|
||
|
||
describe('delivery reports', () => {
|
||
test('reaches the sender as a dlr event', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
@@ -1363,7 +1349,7 @@ describe('delivery reports', () => {
|
||
});
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp({ smsId: 'dlr-id' });
|
||
|
||
return received;
|
||
@@ -1383,10 +1369,8 @@ describe('delivery reports', () => {
|
||
});
|
||
|
||
test('reports a failure with the spec status code', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
@@ -1401,7 +1385,7 @@ describe('delivery reports', () => {
|
||
});
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp({ smsId: 'fail-id' });
|
||
|
||
return received;
|
||
@@ -1417,10 +1401,8 @@ describe('delivery reports', () => {
|
||
});
|
||
|
||
test('sends a text-only receipt to a peer that declared less than 3.4', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const peer = rawPeer(t, smpp.port);
|
||
|
||
peer.write(bindOf(0x33));
|
||
@@ -1438,7 +1420,7 @@ describe('delivery reports', () => {
|
||
seqNr: 2,
|
||
});
|
||
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
|
||
await sms.sendResp();
|
||
await peer.next();
|
||
@@ -1454,10 +1436,8 @@ describe('delivery reports', () => {
|
||
});
|
||
|
||
test('merges nothing for a message that asked for no receipt', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
@@ -1469,7 +1449,7 @@ describe('delivery reports', () => {
|
||
session.on('messageDlr', () => { merged++; });
|
||
|
||
const [sms] = await Promise.all([
|
||
- incoming.then(async received => {
|
||
+ incoming.sms.then(async received => {
|
||
await received.sendResp();
|
||
|
||
return received;
|
||
@@ -1497,10 +1477,8 @@ describe('a session captured from Kannel', () => {
|
||
const expected = 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum';
|
||
|
||
async function replay(t: TestContext, order: number[]): Promise<Sms> {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ const incoming = inbox();
|
||
+ const smpp = await startServer(t, { onSms: incoming.onSms });
|
||
|
||
const sock = net.connect({ port: smpp.port }, () => {
|
||
sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex'));
|
||
@@ -1522,7 +1500,7 @@ describe('a session captured from Kannel', () => {
|
||
}
|
||
});
|
||
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
|
||
await sms.sendResp();
|
||
|
||
@@ -1598,21 +1576,18 @@ describe('robustness', () => {
|
||
});
|
||
|
||
test('keeps at most maxOutstanding requests on the wire', async t => {
|
||
- const smpp = await startServer(t);
|
||
let concurrent = 0;
|
||
let peak = 0;
|
||
-
|
||
- smpp.on('session', session => {
|
||
- session.on('sms', sms => {
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: sms => {
|
||
concurrent++;
|
||
peak = Math.max(peak, concurrent);
|
||
setTimeout(() => {
|
||
concurrent--;
|
||
void sms.sendResp();
|
||
}, 10);
|
||
- });
|
||
+ },
|
||
});
|
||
-
|
||
const { session } = await connect(t, smpp, { maxOutstanding: 2 });
|
||
|
||
assert.ok(session);
|
||
@@ -1653,12 +1628,7 @@ describe('robustness', () => {
|
||
});
|
||
|
||
test('ignores events from the socket it left behind on a reconnect', async t => {
|
||
- const smpp = await startServer(t);
|
||
-
|
||
- smpp.on('session', bound => {
|
||
- bound.on('sms', sms => { void sms.sendResp(); });
|
||
- });
|
||
-
|
||
+ const smpp = await startServer(t, { onSms: sms => sms.sendResp() });
|
||
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } });
|
||
|
||
assert.ok(session);
|
||
@@ -1731,11 +1701,8 @@ describe('robustness', () => {
|
||
|
||
// The guard sits before the send window, or a full window makes the aborted call queue first.
|
||
test('does not wait for a send window slot it will never use', async t => {
|
||
- const smpp = await startServer(t);
|
||
-
|
||
// The peer answers nothing, so the one slot stays held for the whole test.
|
||
- smpp.on('session', session => session.on('sms', () => undefined));
|
||
-
|
||
+ const smpp = await startServer(t, { onSms: () => undefined });
|
||
const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 });
|
||
|
||
assert.ok(session);
|
||
@@ -1972,13 +1939,10 @@ describe('application hooks that throw or reject', () => {
|
||
assert.equal(reported.message, 'authenticate exploded');
|
||
});
|
||
|
||
- test('turns a throwing sms listener into a session error', async t => {
|
||
- const smpp = await startServer(t);
|
||
+ test('turns a throwing sms handler into a session error', async t => {
|
||
+ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } });
|
||
const failed = once<Error>(resolve => {
|
||
- smpp.on('session', session => {
|
||
- session.on('sessionError', resolve);
|
||
- session.on('sms', () => { throw new Error('listener exploded'); });
|
||
- });
|
||
+ smpp.on('session', session => { session.on('sessionError', resolve); });
|
||
});
|
||
const { session } = await connect(t, smpp, { responseTimeout: 200 });
|
||
|
||
@@ -1992,17 +1956,16 @@ describe('application hooks that throw or reject', () => {
|
||
const reported = await raceWithin(500, failed);
|
||
|
||
assert.ok(sent.err instanceof Error);
|
||
- assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session');
|
||
- assert.equal(reported.message, 'listener exploded');
|
||
+ assert.ok(reported instanceof Error, 'a throwing sms handler should reach the session');
|
||
+ assert.equal(reported.message, 'handler exploded');
|
||
});
|
||
|
||
- // The guard for a throwing sms listener used to emit sessionError from inside its own catch.
|
||
+ // The guard for a throwing sms handler used to emit sessionError from inside its own catch.
|
||
test('survives a sessionError listener that throws as well', async t => {
|
||
- const smpp = await startServer(t);
|
||
+ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } });
|
||
|
||
smpp.on('session', session => {
|
||
session.on('sessionError', () => { throw new Error('the reporter exploded too'); });
|
||
- session.on('sms', () => { throw new Error('listener exploded'); });
|
||
});
|
||
|
||
const { session } = await connect(t, smpp, { responseTimeout: 200 });
|
||
@@ -2018,18 +1981,17 @@ describe('application hooks that throw or reject', () => {
|
||
assert.ok(sent.err instanceof Error);
|
||
});
|
||
|
||
- test('normalises whatever a rejecting async sms listener threw into a session error', async t => {
|
||
- const smpp = await startServer(t);
|
||
+ test('normalises whatever a rejecting async sms handler threw into a session error', async t => {
|
||
const reason: unknown = null;
|
||
- const failed = once<Error>(resolve => {
|
||
- smpp.on('session', session => {
|
||
- session.on('sessionError', resolve);
|
||
- session.on('sms', async sms => {
|
||
- await sms.sendResp();
|
||
+ const smpp = await startServer(t, {
|
||
+ onSms: async sms => {
|
||
+ await sms.sendResp();
|
||
|
||
- throw reason;
|
||
- });
|
||
- });
|
||
+ throw reason;
|
||
+ },
|
||
+ });
|
||
+ const failed = once<Error>(resolve => {
|
||
+ smpp.on('session', session => { session.on('sessionError', resolve); });
|
||
});
|
||
const { session } = await connect(t, smpp, { responseTimeout: 200 });
|
||
|
||
@@ -2043,16 +2005,15 @@ describe('application hooks that throw or reject', () => {
|
||
const reported = await raceWithin(500, failed);
|
||
|
||
assert.equal(sent.err, undefined);
|
||
- assert.ok(reported instanceof Error, 'a rejecting sms listener should reach the session');
|
||
+ assert.ok(reported instanceof Error, 'a rejecting sms handler should reach the session');
|
||
assert.equal(reported.message, 'null');
|
||
});
|
||
|
||
test('survives a sessionError listener that rejects as well', async t => {
|
||
- const smpp = await startServer(t);
|
||
+ const smpp = await startServer(t, { onSms: () => Promise.reject(new Error('handler rejected')) });
|
||
|
||
smpp.on('session', session => {
|
||
session.on('sessionError', () => Promise.reject(new Error('the reporter rejected too')));
|
||
- session.on('sms', () => Promise.reject(new Error('listener rejected')));
|
||
});
|
||
|
||
const { session } = await connect(t, smpp, { responseTimeout: 200 });
|
||
@@ -2099,12 +2060,7 @@ describe('application hooks that throw or reject', () => {
|
||
test('sends on through an application logger that throws', async t => {
|
||
const thrower = (): void => { throw new Error('the logger exploded'); };
|
||
const log: SmppLog = { debug: thrower, error: thrower, info: thrower, verbose: thrower, warn: thrower };
|
||
- const smpp = await startServer(t, { log });
|
||
-
|
||
- smpp.on('session', session => {
|
||
- session.on('sms', sms => { void sms.sendResp(); });
|
||
- });
|
||
-
|
||
+ const smpp = await startServer(t, { log, onSms: sms => sms.sendResp() });
|
||
const { session } = await connect(t, smpp, { log });
|
||
|
||
assert.ok(session);
|
||
@@ -2130,12 +2086,7 @@ describe('application hooks that throw or reject', () => {
|
||
});
|
||
|
||
test('keeps the message id off a submit_sm_resp that refuses the message', async t => {
|
||
- const smpp = await startServer(t);
|
||
-
|
||
- smpp.on('session', bound => {
|
||
- bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); });
|
||
- });
|
||
-
|
||
+ const smpp = await startServer(t, { onSms: sms => sms.sendResp({ status: 'ESME_RMSGQFUL' }) });
|
||
const peer = rawPeer(t, smpp.port);
|
||
|
||
peer.write(bindOf(0x34));
|
||
@@ -2269,6 +2220,7 @@ describe('the server\'s onRequest hook', () => {
|
||
}
|
||
|
||
test('refuses an inbound submit_sm with the status the hook chose', async t => {
|
||
+ let messages = 0;
|
||
const smpp = await startServer(t, {
|
||
onRequest: async (bound, pduObj) => {
|
||
if (!isCommand(pduObj, 'submit_sm')) return false;
|
||
@@ -2277,10 +2229,8 @@ describe('the server\'s onRequest hook', () => {
|
||
|
||
return true;
|
||
},
|
||
+ onSms: () => { messages++; },
|
||
});
|
||
- let messages = 0;
|
||
-
|
||
- smpp.on('session', bound => bound.on('sms', () => { messages++; }));
|
||
|
||
const { session } = await connect(t, smpp);
|
||
|
||
@@ -2291,7 +2241,7 @@ describe('the server\'s onRequest hook', () => {
|
||
assert.equal(answered.err, undefined);
|
||
assert.ok(answered.pduObj);
|
||
assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVDSTADR');
|
||
- assert.equal(messages, 0, 'a request the hook answered never reaches the sms event');
|
||
+ assert.equal(messages, 0, 'a request the hook answered never reaches the sms handler');
|
||
});
|
||
|
||
test('answers a bind itself, so a hook that claims every request cannot intercept one', async t => {
|
||
@@ -2332,23 +2282,22 @@ describe('the server\'s onRequest hook', () => {
|
||
assert.deepEqual(seen, [], 'a bind, and everything a peer sends before one, is never the hook\'s');
|
||
});
|
||
|
||
- test('passes a declined request to the sms event, and offers the keepalive and the unbind too', async t => {
|
||
+ test('passes a declined request to the sms handler, and offers the keepalive and the unbind too', async t => {
|
||
const seen: string[] = [];
|
||
+ const incoming = inbox();
|
||
const smpp = await startServer(t, {
|
||
onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; },
|
||
- });
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', bound => bound.on('sms', async sms => {
|
||
- resolve(sms);
|
||
+ onSms: async sms => {
|
||
+ incoming.onSms(sms);
|
||
await sms.sendResp({ smsId: answeredId });
|
||
- }));
|
||
+ },
|
||
});
|
||
const { session } = await connect(t, smpp);
|
||
|
||
assert.ok(session);
|
||
|
||
const answered = await submitTo(session, '46709771337', 'declined by the hook');
|
||
- const sms = await incoming;
|
||
+ const sms = await incoming.sms;
|
||
|
||
await session.send({ cmdName: 'enquire_link' });
|
||
await session.unbind();
|
||
@@ -2361,16 +2310,14 @@ describe('the server\'s onRequest hook', () => {
|
||
});
|
||
|
||
test('reports a hook that throws and answers nothing for it', async t => {
|
||
+ let messages = 0;
|
||
const smpp = await startServer(t, {
|
||
onRequest: () => { throw new Error('the onRequest hook exploded'); },
|
||
+ onSms: () => { messages++; },
|
||
});
|
||
const failed = once<Error>(resolve => {
|
||
smpp.on('session', bound => { bound.on('sessionError', resolve); });
|
||
});
|
||
- let messages = 0;
|
||
-
|
||
- smpp.on('session', bound => bound.on('sms', () => { messages++; }));
|
||
-
|
||
const { session } = await connect(t, smpp, { responseTimeout: 300 });
|
||
|
||
assert.ok(session);
|
||
@@ -2384,16 +2331,14 @@ describe('the server\'s onRequest hook', () => {
|
||
});
|
||
|
||
test('reports a hook that rejects and answers nothing for it', async t => {
|
||
+ let messages = 0;
|
||
const smpp = await startServer(t, {
|
||
onRequest: () => Promise.reject(new Error('the onRequest hook rejected')),
|
||
+ onSms: () => { messages++; },
|
||
});
|
||
const failed = once<Error>(resolve => {
|
||
smpp.on('session', bound => { bound.on('sessionError', resolve); });
|
||
});
|
||
- let messages = 0;
|
||
-
|
||
- smpp.on('session', bound => bound.on('sms', () => { messages++; }));
|
||
-
|
||
const { session } = await connect(t, smpp, { responseTimeout: 300 });
|
||
|
||
assert.ok(session);
|
||
diff --git a/test/tls.test.ts b/test/tls.test.ts
|
||
index 1e311f4..8e3fc14 100644
|
||
--- a/test/tls.test.ts
|
||
+++ b/test/tls.test.ts
|
||
@@ -104,8 +104,9 @@ function createCertificate(): { cert: string; key: string } {
|
||
|
||
const certificate = createCertificate();
|
||
|
||
-async function startServer(t: TestContext): Promise<SmppServer> {
|
||
+async function startServer(t: TestContext, onSms: (sms: Sms) => void = () => undefined): Promise<SmppServer> {
|
||
const { err, server: smpp } = await server({
|
||
+ onSms,
|
||
port: 0,
|
||
tls: { cert: certificate.cert, key: certificate.key },
|
||
});
|
||
@@ -123,10 +124,9 @@ function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
|
||
|
||
describe('tls', () => {
|
||
test('binds over a verified handshake and delivers an SMS', async t => {
|
||
- const smpp = await startServer(t);
|
||
- const incoming = once<Sms>(resolve => {
|
||
- smpp.on('session', session => session.on('sms', resolve));
|
||
- });
|
||
+ let deliver: (sms: Sms) => void = () => undefined;
|
||
+ const incoming = once<Sms>(resolve => { deliver = resolve; });
|
||
+ const smpp = await startServer(t, sms => { deliver(sms); });
|
||
const { err, session } = await client({
|
||
host,
|
||
port: smpp.port,
|
||
diff --git a/test/unsendable.test.ts b/test/unsendable.test.ts
|
||
index 3108910..3383ca5 100644
|
||
--- a/test/unsendable.test.ts
|
||
+++ b/test/unsendable.test.ts
|
||
@@ -56,12 +56,12 @@ describe('an alphabet the caller named that cannot carry the message', () => {
|
||
});
|
||
|
||
// Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space.
|
||
- test('refuses an ASCII send of a character GSM 03.38 has no code for, naming that one', async () => {
|
||
+ test('refuses a GSM send of a character GSM 03.38 has no code for, naming that one', async () => {
|
||
const attempts: PduObjectInput[] = [];
|
||
- const sent = await submitSms(recordingDeps(attempts), { encoding: 'ASCII', from, message: 'Åsa naïve', to });
|
||
+ const sent = await submitSms(recordingDeps(attempts), { encoding: 'GSM', from, message: 'Åsa naïve', to });
|
||
|
||
assert.ok(sent.err instanceof Error);
|
||
- assert.match(sent.err.message, /ASCII/);
|
||
+ assert.match(sent.err.message, /GSM/);
|
||
assert.match(sent.err.message, /"ï"/);
|
||
assert.match(sent.err.message, /U\+00EF/);
|
||
assert.match(sent.err.message, /index 6/);
|
||
@@ -151,7 +151,7 @@ describe('a body the PDU\'s own data_coding cannot carry', () => {
|
||
});
|
||
|
||
assert.ok(built.err instanceof Error, String(dataCoding));
|
||
- assert.match(built.err.message, /ASCII/);
|
||
+ assert.match(built.err.message, /GSM/);
|
||
assert.match(built.err.message, /"ï"/);
|
||
assert.match(built.err.message, /U\+00EF/);
|
||
assert.match(built.err.message, /index 6/);
|
||
diff --git a/todo.md b/todo.md
|
||
index 3e5b9c7..7b9d3ed 100644
|
||
--- a/todo.md
|
||
+++ b/todo.md
|
||
@@ -25,12 +25,13 @@ const { err, session } = await client({ host, password, port, username });
|
||
const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to });
|
||
await session.unbind();
|
||
|
||
-const { err: serverErr, server: smpp } = await server({ authenticate, port });
|
||
-smpp.on('session', session => {
|
||
- session.on('sms', async sms => {
|
||
+const { err: serverErr, server: smpp } = await server({
|
||
+ authenticate,
|
||
+ onSms: async sms => {
|
||
await sms.sendResp();
|
||
if (sms.dlr) await sms.sendDlr('DELIVERED');
|
||
- });
|
||
+ },
|
||
+ port,
|
||
});
|
||
await smpp.close();
|
||
```
|
||
@@ -150,7 +151,7 @@ refuses to delete its default branch.
|
||
dependency versions.** The mocha suite is gone; `node:test` on Node 18 to 26.
|
||
- [x] **Close [#33](https://github.com/larvit/larvitsmpp/issues/33), large inbound text arrives as
|
||
raw `Buffer` segments.** `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one
|
||
- `sms` event.
|
||
+ message for `onSms`.
|
||
- [x] **Close [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request for `message_id`
|
||
in `submit_sm_resp` and spec DLR codes.** All four hold: `sendResp()` always answers a
|
||
`message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the
|
||
@@ -216,13 +217,6 @@ below.
|
||
|
||
### Correctness
|
||
|
||
-- [ ] **Refuse to open a link that dropped while its rebind was answered.** A peer sending
|
||
- `bind_resp` and FIN together can tear the link down before `comeBackUp()` resumes; it then
|
||
- calls `link.open()` on a `down` link, `resetTimers()` skips, and `attempt()` reports success,
|
||
- so the session is `up` on a destroyed socket with nothing to reconnect it until `close()`.
|
||
- `open()` accepting only `binding`, and `comeBackUp()` returning an err when the link is no
|
||
- longer attached, lets the loop retry. Unreproduced; from the stability review of #48.
|
||
-
|
||
- [ ] **Register a multipart send's receipt merge before its segments go out.** `Session.sendSms()`
|
||
calls `dlrMerger.expect()` only once `submitSms()` resolves, after the last segment's response,
|
||
so a receipt for an early segment that arrives first is logged at `debug` as naming no merge,
|
||
@@ -273,10 +267,6 @@ below.
|
||
which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from
|
||
a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run.
|
||
|
||
-- [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before
|
||
- the lost-link check and before the write, so a failed answer still renames the message and a
|
||
- later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run.
|
||
-
|
||
- [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds
|
||
`responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only,
|
||
and Node fires a larger delay after 1 ms. From the 2026-09-28 scoring run.
|
||
@@ -285,11 +275,6 @@ below.
|
||
so `"\x1B("` is detected as GSM, goes out as 0x1B 0x28 and arrives as `{`. From the
|
||
2026-09-28 scoring run.
|
||
|
||
-- [ ] **Count an `sms` listener that throws as one giving up, as a rejection already is.** A
|
||
- synchronous throw makes `Session.emit` return false, and `HeldMessages.offer()` then releases
|
||
- the hold at once, so a drain stops waiting on an async listener still answering beside it.
|
||
- Goal 2. From the stability review of #45.
|
||
-
|
||
### Throughput — goal 6, and the default window is where we are slowest
|
||
|
||
- [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink,
|
||
@@ -301,12 +286,6 @@ below.
|
||
|
||
### Shape — 6 today, and the gate is 7
|
||
|
||
-- [ ] **Answer "is this a bind command" in one place.** `bindCommands` (read by
|
||
- `incoming-requests.ts`, `outgoing-requests.ts` and `test/session.test.ts`) and
|
||
- `bindTypeFromCommand()` (read by `server.ts` and `checkedBind()`) each list the three bind
|
||
- commands, so a fourth added to one is missed by the other. Derive the list from the function,
|
||
- or the reverse. From the stability review of #42.
|
||
-
|
||
- [ ] **Group `src/` into a second level, and retire whichever record loses.** 34 files on one
|
||
plane, where `src/defs/` at 7 proves the shape is known one level down. `docs/decisions.md`
|
||
says "`src/` stays flat until a module has to move for another reason. Valid while that map is
|
||
@@ -320,18 +299,6 @@ below.
|
||
a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly
|
||
that. `max-lines` covers `src/**` only, so nothing has stopped it growing.
|
||
|
||
-- [ ] **Collapse the three objects named `defaults`.** `client.ts`, `server.ts` and
|
||
- `session-options.ts` each export or hold one; `port: 2775` is written twice and the idle
|
||
- timeout is derived two ways to the same 40 000, and 64 MiB is both `defaultMaxOctets` and
|
||
- `defaults.maxHeldOctets`. "What is the default for X" has three answers
|
||
- depending on the entrypoint, and nothing fails when they drift. Named by both architects as the
|
||
- most likely first bug a new contributor ships.
|
||
-
|
||
-- [ ] **Give `hold` one meaning, and rename `IncomingRequests.refusing` for what it does.**
|
||
- `LinkLife.hold()` is a request's budget waiting for a link, `HeldMessages.hold()` a message the
|
||
- application owes an answer; `refusing` decides no refusal — `held.full()` does — and only makes
|
||
- the warn and info lines fire once each way. From the 2026-09-29 scoring run.
|
||
-
|
||
- [ ] **Rename `EncodingName`'s `ASCII` to `GSM7`, with `ASCII` a deprecated alias for one minor.**
|
||
It is GSM 03.38, where `$` is 0x02 and `@` is 0x00, and `segmentUnits.ASCII = 153` is a septet
|
||
budget under a name that says octets. The 2026-09-09 decision removed `consts.ENCODING.ASCII`
|