diff --git a/AGENTS.md b/AGENTS.md index 882bb24..1ca2269 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,38 +40,43 @@ 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) - concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs - dlr.ts Delivery receipts: text and TLV parsing, receipt status codes - dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr + session.ts Session: the public methods and events, the link's life, and the collaborators under session/ + defaults.ts Every option's default, and the bounds that are not options 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 - 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 - link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout 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 - 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 - pdu-transport.ts PduTransport: the socket a session reads complete PDUs off - pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort - reassembly.ts Reassembler: capped, expiring multipart groups - reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness result.ts Result — the shape every fallible call returns - 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 - sms-id.ts Message ids: the peer's notation, the - 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 - uuid.ts uuidv7() — the ids the library generates for messages + session/ What a Session is made of; nothing here is exported + bind-direction.ts Bind types, which end of the link this is, which commands a bind carries + 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: the answer each gets, and the message or report it becomes + link-life.ts LinkLife: whether the link carries requests, and where one waits for the next link + link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout + outgoing-requests.ts OutgoingRequests: one request() in three lanes, the window, the pending map and the retry + pdu-transport.ts PduTransport: the socket a session reads complete PDUs off + pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort + reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness + running-handlers.ts RunningHandlers: the onSms handlers in flight, their bound, and what a drain waits for + send-window.ts SendWindow: the maxOutstanding semaphore + session-options.ts SessionOptions, SessionEvents, SmsHandler, ReconnectOptions and the option checks + messages/ Messages and receipts, on the way in and out + concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs + dlr.ts Delivery receipts: text and TLV parsing, receipt status codes + dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr + expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger and Reassembler share + 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 + reassembly.ts Reassembler: capped, expiring multipart groups + 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 + sms.ts Sms: the handle onSms gets, already answered, and its sendDlr() + sms-id.ts Message ids: the peer's notation, the - 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 + uuid.ts uuidv7() — the ids the library generates for messages + wire/ The codec and the framing + 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 defs/ commands.ts The 33 commands, their ids and ordered parameter lists constants.ts consts + constsById, and the SMPP version constants @@ -82,10 +87,10 @@ src/ types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays ``` -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. +Imports point one way: `defs` knows nothing above it but `result.ts`, `wire` uses `defs`, +`messages` uses `wire`, `session/` uses both, and `client`/`server` use `session`. The ways back up +are the `Session` handed to `createSms()` and `IncomingRequests`, which call back into it, and to +`OnRequest`, `SmsHandler` and `onConnected` in `session-options.ts`, all imported as a type only. **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 @@ -278,7 +283,7 @@ this is not a changelog. - A close arriving after our own `unbind` is a clean unbind, not an error. - `close` means the session is over, and a drop the loop will retry is `disconnected`. -- An answer belongs to the link the message arrived on; a receipt does not. +- An answer goes out on the socket the request arrived on; a receipt on whichever link is up. - `reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off - Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff. - `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and @@ -288,15 +293,13 @@ 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 message is answered on arrival, and `onSms` is a handler the session waits for rather than + an event. - `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, then on the requests on the wire, and a + receipt is let past it. +- Running handlers are capped on a constant, 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. @@ -304,7 +307,8 @@ this is not a changelog. - A message id base is merged at most once. - A send that never reached the socket waits for the next link; one that did is counted, not resent. - A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else. -- One owner decides whether a link can carry a request, and a bind is what makes it one. +- One owner decides whether a link can carry a request, and `bound()` is what makes it one. +- Every request leaves through one `request()`, in one of three lanes. ### [Internals and tests](docs/decisions.md#internals-and-tests) @@ -314,7 +318,7 @@ this is not a changelog. `SendWindow` rather than extracted. - `SmppLog` is a five-method contract this library declares, not a dependency. - The TLS tests build their own self-signed certificate in DER -- `src/` stays flat until a module has to move for another reason. +- `src/` is grouped by what a reader is looking for: the session's parts, the messages, the wire. - `test/` stays flat too, and a file there is named for the question it answers rather than for the module it covers. - CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb5ff7..d7db661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,11 +40,29 @@ count as next to nothing, so a peer could hold far more than the cap. **Raise a `maxOctets` you tuned low**: it now holds several times fewer segments, and an incomplete message evicted over the cap is lost, since its segments were already answered. -- A message arriving while the application holds 1000 unanswered, or 64 MiB of them counted the way - `maxOctets` counts segments, is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a - delivery), so the peer keeps it and retries. **Call `sendResp()` on every `sms`, multipart - included**: 1000 left unanswered now stop inbound traffic for up to five minutes, where the oldest - used to be dropped with a warning. +- **An inbound message goes to the `onSms` option instead of the `sms` event, and arrives already + answered.** `client({ onSms })`, `server({ onSms })` and `new Session({ onSms })` take one + handler; `session.on('sms', …)` is gone. Every message, single or multipart, is answered + `ESME_ROK` with a generated UUID v7 (`sms.smsId`, `-` per segment) before the handler + runs, so `sms.sendResp()`, `SendRespOptions` and `sms.answeredOnArrival` are gone with it. Refuse + a submission from `onRequest`, where it is still unanswered; store your own id against + `sms.smsId`. A session with no `onSms` refuses every message `ESME_RX_P_APPN`, where a session + with no `sms` listener used to leave it unanswered until the peer gave up. +- `close()` and `unbind()` wait for the `onSms` handlers still running (the promise each returned, + up to five minutes each), then for the requests on the wire, and report `N handler(s) still + running` in place of `N message(s) unanswered`. `shutdownTimeout: 0` now waits for the handlers + as it waits for the requests, where it used to fall back to `responseTimeout` for them. + `sms.sendDlr()` is never refused by a shutdown, whenever it is called. +- A message arriving while 1000 `onSms` handlers are still running is refused with + `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a delivery), so the peer keeps it and retries; the 64 MiB + bound on held messages is gone with the hold. **Return from `onSms` when you are done with the + message**: 1000 handlers that never settle stop inbound traffic for up to five minutes. +- `encoding: 'ASCII'` is `encoding: 'GSM7'`, the alphabet it always was; `'ASCII'` is refused by + name. `encodings.GSM7`, `dataCodingByEncoding.GSM7` and `encodeMessage()`'s `encoding` rename + with it. +- A `Session` you construct yourself carries `submit_sm` and `deliver_sm` only once `bound()` has + recorded a bind; `bind_*`, `enquire_link` and `unbind` go out at once, through no send window. + `client()` and `server()` sessions are unchanged. - 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..6bf8bb1 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,84 @@ +# Draft C: the message is answered on arrival, the handler is what the session waits for + +Round one showed the internals were hard because the contract was: a message the application had +to answer, a drain that had to know when it was "done", and a receipt that had to slip past that +drain one turn later. This draft changes the contract so none of that exists. + +## The public contract, as an implementer sees it + +- **Receiving.** `client({ onSms })`, `server({ onSms })`, `new Session({ onSms, sock })`: one + handler, `(sms: Sms) => unknown`. Every message reaches it already answered `ESME_ROK` under + `sms.smsId` (a UUID v7; `-` per segment of a long message), single and multipart alike. + No handler: every message is refused `ESME_RX_P_APPN`. 1000 handlers still running: the next + message is refused `ESME_RTHROTTLED`/`ESME_RX_T_APPN` so the peer retries. A handler that throws + or rejects is a `sessionError`; its message was answered before it ran. +- **Answering.** Nothing to call. To refuse a submission, answer it from `onRequest`, which runs + before the library answers. `sms.sendResp()`, `SendRespOptions` and `answeredOnArrival` are gone. +- **Sending.** `sendSms()`, `send()`, `sms.sendDlr()` as before. A shutdown refuses the first two and + never `sendDlr()`. A request on a bound link with a free slot is on the socket before the call + returns, so a handler's `void sms.sendDlr()` is counted by the drain with no timing rule. +- **Shutting down.** `close()`/`unbind()`: refuse new sends; wait up to `shutdownTimeout` for the + handlers still running (their promise, or five minutes), then for the requests on the wire; tear + down. The `err` says `N handler(s) still running` and/or `N request(s) unfinished`. `0` waits + forever for both; nothing falls back to `responseTimeout` any more. +- **A hand-wired `Session`** carries `submit_sm`/`deliver_sm` once `bound()` records a bind; bind, + `enquire_link` and `unbind` go out at once, outside the window. `encoding: 'ASCII'` is `'GSM7'`. + +## Internal structure and who owns which state + +``` +src/session.ts Session: public methods and events; the link's three transitions, each one method: + linkLost() (socket died/idle/unreadable/failed rebind), end() (over), bound() (opens the link) +src/session/ + link-life.ts phase binding|up|down|ended + stopped; the waiters for a link. Starts `binding`; bound() opens. + outgoing-requests ONE request(input, options, lane). message: drain refusal, link wait, window, retry on + next link. receipt: no drain refusal. link: straight onto the socket. Sync fast path. + incoming-requests the peer's requests: hook -> socket-identity check -> bind gate -> per command. + Answers every message itself, then hands it to RunningHandlers. + running-handlers count of onSms handlers in flight, the 1000 bound with its log hysteresis, the + 5-minute deadline, idle() for the drain. Replaces HeldMessages + MessageHold. + bind-direction bind types, LinkEnd, linkCommands, bindCarries, checkedBind (out of session-options) + session-options option types, SessionEvents (no `sms`), SmsHandler, OnRequest, the option checks +src/defaults.ts every default and every hard bound, once. client/server/session read it. +src/messages/ sms (the handle, no sendResp), send-sms, dlr, dlr-merger (close -> spend), reassembly, … +src/wire/ pdu, pdu-framer, pdu-refusal +``` + +`Session` still hands itself to `IncomingRequests` and `createSms()`: `OnRequest` and `Sms.session` +are public types that name `Session`, so a narrower port would be a second name for the same thing. +What the router may do is now visible in one file: `sendReturn`, `emit`, `close`, `bindAllows`. + +## Deleted + +`held-messages.ts` (HeldMessages, MessageHold, six exits, the WeakMap, `listenerRejected`, the +`setImmediate`), `Session.listenerCount` use, `LinkLife.generation()`/`hold()`, +`OutgoingRequests.requestPastDrain`/`requestOnCurrentLink`/`drain`, `SendWindow.acquire` (now +`take()` + `wait()`), `Session.teardown`/`stop`-then-`emitClose` dance, `Session.answering()`, +`IncomingRequests.refusing`, three `defaults` objects, `backoffDefaults`, `defaultMaxOctets`, +`defaultInterfaceVersion`, `Sms.sendResp`/`answeredOnArrival`, `SendRespOptions`, `SmsHandlers`, +`SmsInput.answeredAs`, the `sms` event, the `ASCII` encoding name. 35 -> 37 files outside `defs/`, grouped. + +## Tests + +`docker compose run --rm node npm test`: lint and typecheck clean, **511 tests, 511 pass, 0 fail** +(baseline 512). Changed, never weaker on the wire: + +- Everywhere: `session.on('sms', …)` -> the `onSms` option; `sendResp()` deleted, and a test that + named its own id now asserts the response carries `sms.smsId`; `answeredOnArrival` assertions gone. + A test that needs a request left unanswered (window held, `unanswered` counted, a drain measured) + holds it in an `onRequest` hook returning `true` and answers it with `sendReturn()` — the wire the + test observes is unchanged. `'ASCII'` -> `'GSM7'`. +- `session-extras`: "held message bounds" -> "running handler bounds" (full at 1000, throttle status, + one warn, `ESME_RX_P_APPN` with no handler, deadline, drain wake-up); "sendResp()" and "refuses an + id and a refusing status for segments already on the wire" deleted (premise gone); "refuses to + answer a message whose link went" -> "sends the receipt … on the new link"; "answers a + single-segment message only once the application does" -> "on arrival, with the id the handler + reads"; "falls back to responseTimeout at shutdownTimeout 0" -> "waits for a handler as long as it + runs"; "still ends when the message half has spent the budget" drives a raw peer; "waits for the + listener still working when another one rejected" and "leaves a message the library refused to + answer unanswered" deleted (one handler; nothing to refuse); LinkLife tests use `budget()`, + boolean `drop()` and start unbound; SendWindow tests use `take()`/`wait()`. +- `session.test.ts`: "keeps at most maxOutstanding requests on the wire" measures concurrency in an + `onRequest` hook; the `sendResp({ smsId: '' })` sub-assertion is gone; `readme.test.ts` follows the + new README examples; `interop-tests/*` and `benchmarks/smsc-sink.ts` (typechecked, not run here) + hold requests via `onRequest`, and dumbclient S9 measures running handlers, not held messages. diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md new file mode 100644 index 0000000..4c5d46d --- /dev/null +++ b/MIGRATION-NOTES.md @@ -0,0 +1,18 @@ +# Breaking changes for a 0.5.0 user + +| Was | Now | +| --- | --- | +| `session.on('sms', async sms => { … })` | `client({ onSms: sms => { … } })`, `server({ onSms })` or `new Session({ onSms, sock })`: one handler, given at construction. | +| `smpp.on('session', s => s.on('sms', …))` | `server({ onSms: sms => { … } })`; `sms.session` is the session the message came in on. | +| `await sms.sendResp()` | Delete it: every message is answered `ESME_ROK` on arrival, before the handler runs. | +| `sms.sendResp({ smsId: myId })` | Store `myId` against `sms.smsId`, the UUID v7 the peer was answered with. | +| `sms.sendResp({ status: 'ESME_RMSGQFUL' })` | `server({ onRequest: async (session, pduObj) => { …; await session.sendReturn(pduObj, 'ESME_RMSGQFUL'); return true; } })`. | +| `if (sms.answeredOnArrival) …` | Delete the branch: it is always the case. | +| `import type { SendRespOptions }` | Delete it; `SmsHandler` is the new exported type. | +| `session.on('sms', listener)` with no listener at all | A session with no `onSms` refuses every message `ESME_RX_P_APPN` instead of leaving it unanswered. | +| `close()` waits for "unanswered messages" | It waits for `onSms` handlers still running; `err.message` reads `… N handler(s) still running` instead of `… N message(s) unanswered`. | +| `shutdownTimeout: 0` falls back to `responseTimeout` for the messages | It waits for the handlers as for the requests; each handler is bounded by its own five-minute deadline. | +| `sendDlr()` straight after `sendResp()` to get past a drain | `sendDlr()` is never refused by a shutdown; call it whenever. | +| 1000 unanswered messages or 64 MiB of them throttle the peer | 1000 running handlers throttle the peer; the octet bound is gone. | +| `encoding: 'ASCII'` | `encoding: 'GSM7'`; likewise `encodings.GSM7`, `dataCodingByEncoding.GSM7`, `encodeMessage(…).encoding === 'GSM7'`. | +| `new Session({ sock })` then `session.send({ cmdName: 'submit_sm' })` | Call `session.bound('transceiver', 0x34)` (or have the peer bind) first; bind, `enquire_link` and `unbind` still go out at once. | diff --git a/MIGRATION.md b/MIGRATION.md index 0296ede..04ca649 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -6,15 +6,16 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re ## API changes - **The package is `@larvit/smpp`** and ESM only. `require()` no longer works. -- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp`, `sendDlr`, `unbind` and - `session.close` are promises resolving to a result with an optional `err`. Nothing rejects. Await - `close()`, or the socket outlives the call. +- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendDlr`, `unbind` and `session.close` + are promises resolving to a result with an optional `err`. Nothing rejects. Await `close()`, or the + socket outlives the call. +- **The `sms` event is the `onSms` option**, on `client()`, `server()` and `Session`, and every + message reaches it already answered: `sendResp()` is gone. Refuse a submission from `onRequest`. - **`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 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 - `'use strict'`), and is ignored otherwise. +- **The id a message is answered with is the library's**: `sms.smsId` is the generated UUID v7 the + peer already has, and the segments of a long message were answered `-`. Store your own + id against it. - **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with `pduObjs`, `undefined` where the SMSC took the segment without naming an id. - **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and @@ -44,7 +45,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a `larvitutils` one, and is silent by default: [README](README.md#logging). - **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP - 3.4 5.2.19 gives 0x01. `dataCodingByEncoding` is the alphabet `sendSms()` writes, which is 0x00. + 3.4 5.2.19 gives 0x01. The `encoding` option's GSM 03.38 alphabet is `GSM7`, and + `dataCodingByEncoding.GSM7` is what `sendSms()` writes it under, which is 0x00. ## Behaviour that changed on the wire diff --git a/README.md b/README.md index 9ff2b7d..06168a0 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 handlers and for what is on the wire, 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,33 @@ 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: sms => { + // sms.from, sms.to, sms.message, sms.smsId + }, }); ``` -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). +Every message reaches the handler already answered: the peer got `ESME_ROK` and `sms.smsId` when +it arrived, whole or reassembled from segments. The handler's promise is what the session waits +for on shutdown, and while 1000 of them are running new messages are refused so the peer retries. +Without a handler, every message is refused. Delivery receipts reach you as `dlr` events, not here: +[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 - await sms.sendResp(); - }); +const { err, server: smpp } = await server({ + onSms: sms => { + // sms.from, sms.to, sms.message, sms.dlr, sms.smsId, sms.session + }, }); +if (err) throw err; ``` With authentication and delivery reports: @@ -134,34 +134,25 @@ const { err, server: smpp } = await server({ return { userData: { userId: 123 } }; }, -}); -if (err) throw err; - -smpp.on('session', session => { - session.on('sms', async sms => { - if (sms.answeredOnArrival) { - await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain - } else { - // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) - await sms.sendResp(); - } - + onSms: async sms => { + // Already answered ESME_ROK under sms.smsId; sms.session.userData is what authenticate returned. 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. +- Every submission is answered `ESME_ROK` as it arrives, with a generated UUID v7 as the message + id: `sms.smsId`, and `-1`, `-2` and so on for the segments of a long message. To + refuse one, answer it from `onRequest` before the library does: [Server in depth](#server-in-depth). - `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. + `sendDlr('UNDELIVERABLE')` any other state. +- One `onSms` serves every session the server accepts; `sms.session` is the one a message came in on. ## Errors @@ -182,8 +173,8 @@ Neither is named `error`, because Node throws on an unhandled `error` event. | Kind | Type | | | --- | --- | --- | | 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. | +| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and `onSms` never saw it. | `Error` | Count it as lost traffic. | +| 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: @@ -209,7 +200,7 @@ session.on('sessionError', err => { `seqNr`. `cmdName` is undefined for a command id this library does not know. `PduHeader` is its type. - A refused request is answered with the status SMPP names for it. A refused response is answered with nothing, and settles the request it named as `unanswered`. -- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never arrives as `sms` +- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never reaches `onSms` or `dlr`. A refused response is reported twice, as the `err` of the `sendSms()` or `send()` waiting on it and here. - A `PduRefusedError` is always a PDU that arrived. What this library refuses to build or send (an @@ -231,8 +222,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 requests already sent. `0` waits forever: a handler ends when its promise settles or five minutes pass, a request when the peer answers or `responseTimeout` expires, so `0` for both never ends. | | `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. | +| `onSms` | none | `(sms) => Promise \| void`: every mobile-originated message, already answered. Without one, the SMSC's deliveries are refused: [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 +249,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` | none | `(sms) => Promise \| void`: every message a bound peer submits, on any session, already answered. Without one, submissions are refused: [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 +291,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 | +| `GSM7` | 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: `GSM7` 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 +350,11 @@ holds for `session.send()`. ### Events +An inbound message is not an event: it goes to the `onSms` handler, because the session waits for +that handler where it waits for no listener. + | 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 +371,16 @@ 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. -3. Tear down what is left, resolving to an `err` that says what was lost. +1. Refuse further `sendSms()` and `send()` calls. `sms.sendDlr()` is never refused by a shutdown: + it reports on a message this session took, and goes out like any request already on the wire. +2. Wait up to `shutdownTimeout` for every `onSms` handler still running, then for the requests + already sent. A handler is running until the promise it returned settles, or five minutes pass. +3. Tear down what is left, resolving to an `err` that says what was lost: handlers still running, + requests unfinished, or a link that went mid-drain. -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. +`responseTimeout` for its own response. `SmppServer.close()` reports each session's unfinished +drain as `serverError`. **Sends and the link.** @@ -396,9 +390,7 @@ A message left unanswered for five minutes is no longer waited for. you abort it, fails and counts in `unanswered`: the SMSC may have taken it and lost only the response. - With `reconnect: false` a drop ends the session, and every send after it is refused. -- `sms.sendResp()` on a message whose link dropped writes nothing and returns `err`, since a response - carries the sequence number of the link it arrived on. `sms.sendDlr()` still goes out on the new - link. +- `sms.sendDlr()` for a message whose link dropped goes out on the new link. - `responseTimeout` bounds the wait for a link and the wait for an answer separately, and the wait for a `maxOutstanding` slot is unbounded, so it is not a deadline. For a deadline pass `{ signal: AbortSignal.timeout(ms) }`: it cuts all three waits short, and a send it stops before @@ -437,22 +429,23 @@ const { err, pduObj } = await session.send({ ## Receiving in depth - **Multipart.** Segments tied together by a user data header, or by the `sar_msg_ref_num`, - `sar_total_segments` and `sar_segment_seqnum` TLVs, reassemble into one `sms` alike. A PDU carrying + `sar_total_segments` and `sar_segment_seqnum` TLVs, reassemble into one message alike. A PDU carrying both is read from the header. The two reference numbers are separate counters: the same number in 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. +- **Answered on arrival.** Each segment was answered as it landed, before the handler sees the + message: [Server in depth](#server-in-depth). +- **Handlers still running.** While 1000 `onSms` handlers have not settled, every new message and + segment is refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on + a delivery. Reaching the bound logs one `warn`, and the first message accepted once it is down to + half one `info`. A handler still running after five minutes is dropped from the count with a + `warn`. Neither number is an option. +- **No handler.** A session with no `onSms` refuses every message with `ESME_RX_P_APPN`, so the + peer is not left waiting for an answer that never comes. - **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`. -- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message arrives as `sms`, a - receipt as `dlr`. A `server()` session reads it as a submission and always emits `sms`. Either way +- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message goes to `onSms`, a + receipt to `dlr`. A `server()` session reads it as a submission and always hands it to `onSms`. Either way it is answered `data_sm_resp`. - **Flash.** `sms.flash` is true where `data_coding` carries GSM 03.38 message class 0, in every coding group that carries one: `0x10`, `0x18`, `0x50` and `0xF0` alike. Classes 1 to 3 name where @@ -501,23 +494,20 @@ even where the SMSC took some of its segments; their receipts still arrive as `d ## Server in depth -**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC -will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment -numbers itself into no message this session can join, which refuses it, or the reassembly buffer or -the unanswered messages are at their bound, which asks the SMSC to keep it and try again. -`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count -cannot, since a peer may number a message one part of one. +**Every message is answered on arrival.** Each segment is answered `ESME_ROK` as it lands, because +a relaying SMSC will not send the next until the last is answered, and a whole message the same way +for the same reason. The exceptions refuse it: a segment that numbers itself into no message this +session can join, or a reassembly buffer or the running handlers at their bound, which asks the SMSC +to keep it and try again. -- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and - releases the message, and returns `err` for an `smsId` or a refusing `status`. - `sms.smsId` is the base. `sendDlr()` names `-1`, `-2` and so on: the ids the `submit_sm` responses carried. - A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound message's base is a handle of your own only. -**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: +**Refusing a request** (a full queue, an unknown recipient, an unauthorised sender) has to land +before the library answers it. `onRequest` runs on every request a bound peer sends, before +reassembly and before `onSms`: ```javascript import { isCommand, server } from '@larvit/smpp'; @@ -545,7 +535,8 @@ if (err) throw err; `authenticate` itself. - A hook that throws or rejects reaches `sessionError`, and nothing is written for that request; the peer's own response timeout settles it. `authenticate` fails the same way, leaving the bind - unanswered. + unanswered. An `onSms` handler that fails reaches `sessionError` too, but its message was + answered before it ran. - `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop the link. Guard on the command name, as above, and a failing hook costs only its own request. - A `Session` you construct yourself takes the same hook as a session option, and that is where a @@ -668,7 +659,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, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `SmsHandler`, `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..02a2e5d 100644 --- a/benchmarks/smsc-sink.ts +++ b/benchmarks/smsc-sink.ts @@ -5,22 +5,14 @@ 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: () => { answered++; }, 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..a288851 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -18,16 +18,14 @@ rule and an index of the titles below. - **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 + `session.on('dlr', async dlr => …)` a consumer writes 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` 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 is never waited on, which is why + an inbound message is a handler and not 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 @@ -234,7 +232,7 @@ rule and an index of the titles below. coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the - class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins + class bit clear — stays GSM7, because below 0x10 SMPP's flat table contradicts 03.38 and wins (0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is spelling 03.38 at all. Send-side: `flash` is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while @@ -361,7 +359,7 @@ rule and an index of the titles below. - **An alphabet the caller named has to carry the message, and a time the format cannot express is refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on - `あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'` + `あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'GSM7'` flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote `NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can @@ -439,7 +437,7 @@ rule and an index of the titles below. - **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.** Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet - through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT + through `consts.ENCODING`, so `encoding: 'GSM7'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written @@ -456,7 +454,7 @@ rule and an index of the titles below. researched peer means IA5 by it. The two tables agree over most of the printable range and part at 0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed, carriage return and escape are common to both — which is where a peer that did mean IA5 is - misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two + misread. Accepted with it: `consts.ENCODING` loses its `GSM7` alias and keeps `IA5`, the two names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different values and so the only one a reader could carry from the option's vocabulary into SMPP's flat table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name. @@ -508,10 +506,11 @@ rule and an index of the titles below. Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on `close` ends up holding two binds on one account, which goal 4 forbids. -- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call, - 2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response - that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused - response names an id the peer has no record of. +- **An answer goes out on the socket the request arrived on; a receipt on whichever link is up.** + Maintainer's call, 2026-09-01, narrowed 2026-09-29: a response carries the request's sequence + number, which correlates with nothing on another link, so `IncomingRequests.handle()` drops a + request whose socket went while `onRequest` ran rather than answering it on the next one — goal 2's + wrong answer. A receipt names a message id, which the peer keeps across links. - **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a @@ -583,39 +582,36 @@ 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 - Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will - not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole - deadlocked every multi-segment message against a production gateway - ([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). Goal 1 has the answer - a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is - generated when it opens and each segment is answered `-`, the notation `sms-id.ts` owns - and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId` - or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent - no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the - discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is - untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for - an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()` - answers every segment it will not carry rather than leaving it unanswered, which is the same stall - in miniature: the field that numbered it where the segment belongs to no group, the retry status - where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: - answering every segment but the one that completes the group, which leaves the peer holding some - segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour - a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment, - which asks the application to name a message it cannot read yet — what it wants is `sms.smsId` - afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only - distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or - dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError` - as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the - `PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the - application could act on, and goal 8 does not buy a second exported class to make a count - distinguishable. Accepted: a completing segment whose own answer the socket would not carry still - reaches the application, because the message is whole and correct and the failed answer is on - `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message - in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can - never precede the acceptance of the message it reports on. - +- **Every message is answered on arrival, and `onSms` is a handler the session waits for rather than + an event.** Maintainer's call, 2026-09-06, widened 2026-09-29 from concatenated messages to every + message. Jasmin dispatches one `submit_sm` per connector at a time and will not send segment 2 + until segment 1 is answered, so holding a group unanswered until it was whole deadlocked every + multi-segment message against a production gateway + ([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)); goal 1 has the + answer a real SMSC gives — one `message_id` per `submit_sm`, immediately — so each segment is + answered `-` off a base generated when its group opens, the notation `sms-id.ts` owns and + `DlrMerger` reads back. A single-segment message used to be the application's to answer through + `sendResp()`, which made two contracts of one: an `answeredOnArrival` flag, a `sendResp()` that + wrote nothing on one kind of message and refused an `smsId` on it, and a drain that had to know + when the application was "done" with a message — six ways out of a hold, one of them a + `setImmediate`, one a listener count. Goal 8's small surface wins: one rule, the answer is the + library's, and the application's part is the handler. `onSms` is an option rather than an event + because its promise is what the session waits for on shutdown and what the running-handler bound + counts, and no EventEmitter listener is waited for anywhere else. A session with no handler + refuses every message `ESME_RX_P_APPN` rather than answering `ESME_ROK` and dropping it, since + work the peer has no reason to send again is not dropped (goal 2), and rather than leaving it + unanswered, which holds the peer's window for its timeout (goal 4). A handler that throws or + rejects reaches `sessionError` and nothing else: its message was answered before it ran, so there + is nothing left to answer on its behalf. `onRequest` is where a request is refused, since it runs + before the answer. Rejected: keeping the single-segment answer with the application, the two + contracts above. Rejected: an awaited `sms` event, where zero listeners, two listeners and a + rejecting one each need a rule. Accepted: an application cannot name the `message_id` a submission + is answered with; `sms.smsId` is a UUID v7 it stores a mapping under. Accepted: a completing + segment whose own answer the socket would not carry still reaches the handler, because the message + is whole 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. A group given up on — expired, evicted, or dropped + with the link — is traffic the peer will not send again, so each one reaches `sessionError` as + well as the log. - **`server()` composes the application's `onRequest` after its own bind handling, and offers it every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch @@ -653,40 +649,30 @@ 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. - Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again - (goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application - still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected: - pausing the socket, which also stalls every answer and `enquire_link` on the link. Reaching the - bound shows only in the log (goal 8): an event or a public count would be surface for what the - application already knows, since it is the one not answering. A message held past its timeout is - still dropped, so `close()` can report fewer unanswered than there were — accepted, because the - alternative is holding what nothing will answer. - +- **The drain waits on the `onSms` handlers still running, then on the requests on the wire, and a + receipt is let past it.** Maintainer's call, 2026-09-01, restated 2026-09-29: waiting on the send + window alone tore a server session down while the application was still working on a message, and + a receipt it then sent was refused as a send after `close()`. The handler's promise is the one + signal of "done", so nothing on `Sms` says it; `sendDlr()` takes the `receipt` lane, which a + shutdown never refuses, because a receipt reports on a message this session took and the window + wait still bounds it. `shutdownTimeout: 0` waits for the handlers as it waits for the requests: + the five-minute handler deadline bounds that half on its own, so no fallback to `responseTimeout` + is needed. Rejected: counting every inbound request until `sendReturn()` answered it, since an + `onRequest` that deliberately answers nothing would cost a full `shutdownTimeout` on every close. + +- **Running handlers are capped on a constant, and a message past the cap is refused.** A bound the + application cannot raise is the point: a handler that never settles would otherwise pile up + without limit, which goal 4 forbids. Reassembly's `maxOctets` is an option because it bounds what + the peer sends; this bounds what the application has not finished. Maintainer's call, 2026-09-26. + Refusing leaves the message with the peer, which will send it again (goal 2). 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 its handlers are the ones not finishing. A handler past its + deadline is dropped from the count, so `close()` can report fewer running than there are — + accepted, because the alternative is waiting on what nothing will end. - **A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery, a `data_sm` by whichever it stands in for.** Maintainer's call, 2026-09-26, for - reassembly and held messages alike, so "keep it and retry" has one spelling per direction. + reassembly and running handlers alike, so "keep it and retry" has one spelling per direction. `ESME_RTHROTTLED` asks the sender to slow down, which is what the peer outrunning us needs, and operators send it (Vonage, LINK Mobility, Route Mobile, Jasmin), so clients built against them meet it (goal 1). Rejected: `ESME_RMSGQFUL`, which names an exhausted queue and no rate. @@ -750,14 +736,28 @@ rule and an index of the titles below. an abort while held for a link already gives. The drain half needs nothing: `close({ signal })` already hands the signal to `window.idle()`, and `unbind()` taking none is the shape README states. -- **One owner decides whether a link can carry a request, and a bind is what makes it one.** - Maintainer's call, 2026-09-01, extended 2026-09-28; goal 1, since a send on a link not yet bound - comes back `ESME_RINVBNDSTS`. `LinkLife` is told what happened and never reads back into the - session; every other collaborator reads it and keeps no copy. Rejected: gating on the socket being - attached, which admits a send one round trip before the bind is answered, and collaborators that - ask the session, which answered the same question two ways at admit and at release. - `ReconnectLoop.halted` is the one other flag, because `client()` also runs a loop with no session - behind it for `fromStart`; a session's loop is stopped by `Session.stop()` alone. +- **One owner decides whether a link can carry a request, and `bound()` is what makes it one.** + Maintainer's call, 2026-09-01, extended 2026-09-28 and 2026-09-29; goal 1, since a send on a link + not yet bound comes back `ESME_RINVBNDSTS`. `LinkLife` starts at `binding` on every socket and + `Session.bound()` opens it, so a hand-wired session and `server()`'s carry nothing until the bind + they record; `LinkLife` is told what happened and never reads back into the session, and every + other collaborator reads it and keeps no copy. Rejected: gating on the socket being attached, which + admits a send one round trip before the bind is answered, and collaborators that ask the session, + which answered the same question two ways at admit and at release. `ReconnectLoop.halted` is the + one other flag, because `client()` also runs a loop with no session behind it for `fromStart`; a + session's loop is stopped by `Session.stop()` alone. + +- **Every request leaves through one `request()`, in one of three lanes.** Maintainer's call, + 2026-09-29, from the second comprehension round: `request`, `requestPastDrain` and + `requestOnCurrentLink` each skipped a different subset of the checks, and a reader had to diff + them. `message` takes every check; `receipt` is the same less the shutdown refusal; `link` — a + bind, an unbind, a keepalive — goes out now on the socket as it is, since a bind is what makes the + link carry anything, an unbind follows a drain that may have left the window full, and a keepalive + behind a full window would let the idle timer drop a healthy link. The lane is chosen by the + command name in `Session.send()` and by `sendDlr()` for a receipt; nothing else names one. A + request on a bound link with a free slot reaches the socket before `request()` returns, so a + handler's `void sms.sendDlr()` is in the window by the time the handler's promise settles — a + mechanical fact, where the old `setImmediate` was a timing contract. ## Internals and tests @@ -795,11 +795,14 @@ rule and an index of the titles below. fail on every developer machine, and a committed key leaks in a public repository. Valid while the dev image has no openssl. -- **`src/` stays flat until a module has to move for another reason.** Architecture review, - 2026-09-06: the grouping the [file map](../AGENTS.md#architecture) already implies — `wire/` for `pdu*` and `defs`, - `link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`, - `dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to - `dist/index.js`, the one published entry. Valid while that map is what a reader navigates by. +- **`src/` is grouped by what a reader is looking for: the session's parts, the messages, the + wire.** Maintainer's call, 2026-09-29, reversing the 2026-09-06 review's "stay flat until a module + has to move": that decision was valid while the file map was what a reader navigated by, and two + scoring rounds reported that the map had become AGENTS.md rather than the tree. `session/` holds + what only `Session` composes, `messages/` what a message or a receipt is made of on either way, + `wire/` the codec and framing; the eight files at the top are the three public entry points, the + session, the defaults and the three shared shapes. Rejected: a `link/` beside `session/`, which + would split the session's parts on a line nothing in the code draws. - **`test/` stays flat too, and a file there is named for the question it answers rather than for the module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest diff --git a/eslint.config.js b/eslint.config.js index 755c9c0..e3d4a6a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,7 +40,7 @@ export default tseslint.config( }, { // ESLint counts every ?. and ?? in dlrFromPdu as a branch; the 19 is 26 lines of flat field resolution. - files: ['src/dlr.ts'], + files: ['src/messages/dlr.ts'], rules: { complexity: ['error', 19] }, }, { diff --git a/interop-tests/cloudhopper.test.ts b/interop-tests/cloudhopper.test.ts index dca5b5b..434eebf 100644 --- a/interop-tests/cloudhopper.test.ts +++ b/interop-tests/cloudhopper.test.ts @@ -1,9 +1,12 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test, { after, describe } from 'node:test'; +import type { OnRequest } from '../src/session/session-options.ts'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import type { SmppServer } from '../src/server.ts'; +import { isCommand } from '../src/wire/pdu.ts'; +import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080'; @@ -38,26 +41,40 @@ async function driver(path: string, params: Record = {}): Promis return response.json() as Promise; } -const manualTexts = new Set(); +/** How long a submit carrying this text is held before it is answered, where not SLOW_DELAY_MS. */ +const holdMs = new Map(); +const refusedTexts = new Set(); const allSms: { session: Session; sms: Sms }[] = []; -function attach(session: Session): void { - session.on('sms', sms => { - allSms.push({ session, sms }); +// 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. +const holdOrRefuse: OnRequest = async (session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; - if (manualTexts.has(sms.message)) return; + const text = paramText(pduObj.params.short_message); - // 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()); - }); -} + if (refusedTexts.has(text)) { + await session.sendReturn(pduObj, 'ESME_RMSGQFUL'); + + return true; + } + + await delay(holdMs.get(text) ?? SLOW_DELAY_MS); -const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT }); + return false; +}; + +const serverOptions = { + authenticate: () => true, + idleTimeout: 40_000, + onRequest: holdOrRefuse, + onSms: (sms: Sms) => { allSms.push({ session: sms.session, sms }); }, +}; + +const { err: serverErr, server: smpp } = await server({ ...serverOptions, 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'); @@ -65,15 +82,13 @@ const cert = readFileSync('/shared-certs/server.crt'); // findings/05-java-clients.md, Peer quirks. Capped here for S10 only; the plain listener above is // unrestricted. const { err: tlsServerErr, server: tlsSmpp } = await server({ - authenticate: () => true, - idleTimeout: 40_000, + ...serverOptions, 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(); @@ -139,20 +154,15 @@ describe('S5 - request expiry shorter than the handler delay (target 11)', () => const text = 'expiry-probe'; - manualTexts.add(text); + // Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window + // monitor gives up on it first - recorded, not asserted against, since that is the peer's call. + holdMs.set(text, 1000); - const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' }); + const result = await driver('/submit', { session: 'expiry', text, timeoutMs: '5000' }); const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); assert.ok(sms); - // Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window - // monitor gives up on it first - recorded, not asserted against, since that is the peer's call. - await delay(1000); - await sms.sendResp(); - - const result = await submitted; - assert.equal(result.ok, false); // Cloudhopper's window monitor gives up on the expired slot with a RecoverablePduException, // not the SmppTimeoutException its own per-call timeoutMs would throw - the two expiries are @@ -189,7 +199,7 @@ describe('S10 - Cloudhopper SSL client against our server({ tls })', () => { }); describe('a refusing status is surfaced back to Cloudhopper', () => { - test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches Cloudhopper in the response', async () => { + test('sendReturn(pduObj, "ESME_RMSGQFUL") from onRequest reaches Cloudhopper in the response', async () => { const bind = await driver('/bind', { password: 'chpw', session: 'refuse', systemId: 'ch-refuse' }); assert.equal(bind.ok, true); @@ -197,18 +207,13 @@ describe('a refusing status is surfaced back to Cloudhopper', () => { const text = 'ch-refuse-me'; - manualTexts.add(text); - - const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' }); - const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); - - assert.ok(sms); - await sms.sendResp({ status: 'ESME_RMSGQFUL' }); + refusedTexts.add(text); - const result = await submitted; + const result = await driver('/submit', { session: 'refuse', text, timeoutMs: '5000' }); assert.equal(result.ok, true); assert.equal(result.commandStatus, 0x00000014); + assert.equal(allSms.some(entry => entry.sms.message === text), false); await driver('/unbind', { session: 'refuse' }); }); }); diff --git a/interop-tests/compose.dumbclient.yaml b/interop-tests/compose.dumbclient.yaml index e6c4278..ba6c83e 100644 --- a/interop-tests/compose.dumbclient.yaml +++ b/interop-tests/compose.dumbclient.yaml @@ -32,7 +32,7 @@ services: dumbclient-netns: condition: service_started - # S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing + # S9's comparison run: window below maxRunningHandlers (1000, src/defaults.ts), where nothing # should ever be throttled - see findings/07-load.md. dumbclient-w500: build: ./interop-tests/peers/dumbclient diff --git a/interop-tests/dumbclient.test.ts b/interop-tests/dumbclient.test.ts index 81bc9ff..ecf5352 100644 --- a/interop-tests/dumbclient.test.ts +++ b/interop-tests/dumbclient.test.ts @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; import test, { after, describe } from 'node:test'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import type { LogMethod, SmppLog } from '../src/log.ts'; import { server } from '../src/server.ts'; const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775'); -/** Slower than every scenario's submission rate (2000/s for the window runs), so a real backlog - * presses on the configured window instead of draining as fast as it fills - see findings/07-load.md. */ +/** Slower than every scenario's submission rate (2000/s for the window runs), so a real backlog of + * running handlers builds instead of draining as fast as it fills - see findings/07-load.md. */ const SLOW_HANDLER_DELAY_MS = 2; function delay(ms: number): Promise { @@ -37,8 +37,6 @@ function scenarioOf(session: Session): string { } type ScenarioStats = { - answerOrder: number[]; - answered: number; arrived: number; // Set from a 'close' listener attached the moment the session is first seen (smpp.on('session')), // never lazily inside a test body - a session can close well before a test gets around to @@ -46,9 +44,10 @@ type ScenarioStats = { // replays an event to a listener added after it fired. closed: boolean; duplicateIds: number; + handled: number; + handledOrder: number[]; ids: Set; - peakOutstanding: number; - unansweredErrors: number; + peakRunning: number; }; const stats = new Map(); @@ -59,14 +58,13 @@ function statsFor(name: string): ScenarioStats { if (existing) return existing; const created: ScenarioStats = { - answerOrder: [], - answered: 0, arrived: 0, closed: false, duplicateIds: 0, + handled: 0, + handledOrder: [], ids: new Set(), - peakOutstanding: 0, - unansweredErrors: 0, + peakRunning: 0, }; stats.set(name, created); @@ -101,63 +99,66 @@ const memTimer = setInterval(() => { memTimer.unref(); -const { err, server: smpp } = await server({ - authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), - idleTimeout: 40_000, - log, - port: SMPP_PORT, -}); - -assert.equal(err, undefined); -assert.ok(smpp); - -const serverErrors: Error[] = []; - -smpp.on('serverError', serverError => { serverErrors.push(serverError); }); - const sessionByScenario = new Map(); const slowQueues = new Map>(); -function answered(session: Session, arrivalIndex: number, result: { err?: Error }): void { +function handled(session: Session, arrivalIndex: number): void { const s = statsFor(scenarioOf(session)); - s.answered++; - s.answerOrder.push(arrivalIndex); - if (result.err) s.unansweredErrors++; + s.handled++; + s.handledOrder.push(arrivalIndex); } -function slowRespond(session: Session, sms: Sms, arrivalIndex: number): void { - const chain = (slowQueues.get(session) ?? Promise.resolve()) +/** One message at a time per session, SLOW_HANDLER_DELAY_MS each, so the handlers behind it stay running. */ +function slowHandle(sms: Sms, arrivalIndex: number): Promise { + const chain = (slowQueues.get(sms.session) ?? Promise.resolve()) .then(async () => { await delay(SLOW_HANDLER_DELAY_MS); }) - .then(async () => { answered(session, arrivalIndex, await sms.sendResp()); }); + .then(() => { handled(sms.session, arrivalIndex); }); - slowQueues.set(session, chain); -} + slowQueues.set(sms.session, chain); -function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void { - void sms.sendResp().then(result => { answered(session, arrivalIndex, result); }); + return chain; } -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; }); +function onSms(sms: Sms): Promise | undefined { + const { session } = sms; + const name = scenarioOf(session); - session.on('sms', sms => { - const name = scenarioOf(session); + sessionByScenario.set(name, session); - sessionByScenario.set(name, session); + const s = statsFor(name); + const arrivalIndex = s.arrived; - 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.peakRunning = Math.max(s.peakRunning, s.arrived - s.handled); - 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-w2000') return slowHandle(sms, arrivalIndex); - if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex); - else fastRespond(session, sms, arrivalIndex); - }); + handled(session, arrivalIndex); + + return undefined; +} + +const { err, server: smpp } = await server({ + authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), + idleTimeout: 40_000, + log, + onSms, + port: SMPP_PORT, +}); + +assert.equal(err, undefined); +assert.ok(smpp); + +const serverErrors: Error[] = []; + +smpp.on('serverError', serverError => { serverErrors.push(serverError); }); + +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; }); }); function memShape(): string { @@ -196,7 +197,7 @@ after(async () => { clearInterval(memTimer); report(`memory shape: ${memShape()}`); for (const [name, s] of stats) { - report(`${name}: arrived=${String(s.arrived)} answered=${String(s.answered)} duplicateIds=${String(s.duplicateIds)} peakOutstanding=${String(s.peakOutstanding)} unansweredErrors=${String(s.unansweredErrors)}`); + report(`${name}: arrived=${String(s.arrived)} handled=${String(s.handled)} duplicateIds=${String(s.duplicateIds)} peakRunning=${String(s.peakRunning)}`); } await smpp.close(); @@ -205,22 +206,24 @@ 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 -// 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'; - -// window500's peak (<=500) and the soak's never reach the 1000 default, so every refusal is -// necessarily from the w2000 session - the runs share one server and one log. +// handler slowed enough to build a real backlog of handlers still running. maxRunningHandlers +// (1000, defaults.ts bounds.maxRunningHandlers) is the bound past which a message 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 handled plus throttled. window500 is the same run against a +// handler that finishes at once: every message is answered on arrival, so the peer's window bounds +// nothing here, and only the handler's own pace decides whether the bound is reached. +const throttleMessage = 'session - handlers at their bound, asking the peer to retry'; + +// Only w2000's handler is slow, so every refusal is necessarily from the w2000 session - the runs +// share one server and one log, and w500 arriving whole is what proves it. function throttled(name: string): number { return name === 'dumb-w2000' ? logEntries.filter(entry => entry.message === throttleMessage).length : 0; } -describe('S9 - bounded window against a slowed handler', () => { +describe('S9 - bounded handler backlog against a slowed handler', () => { for (const name of ['dumb-w500', 'dumb-w2000'] as const) { - test(`${name}: every message answered or throttled exactly once, ordering holds`, async () => { - const done = await waitFor(() => (statsFor(name).answered + throttled(name) >= 20_000 ? true : undefined), 180_000); + test(`${name}: every message handled or throttled exactly once, ordering holds`, async () => { + const done = await waitFor(() => (statsFor(name).handled + throttled(name) >= 20_000 ? true : undefined), 180_000); assert.ok(done, `${name} did not account for 20000 messages within budget`); @@ -228,18 +231,17 @@ describe('S9 - bounded window against a slowed handler', () => { const expectedCount = 20_000 - throttled(name); assert.equal(s.arrived, expectedCount); - assert.equal(s.answered, expectedCount); + assert.equal(s.handled, expectedCount); assert.equal(s.duplicateIds, 0); - assert.equal(s.unansweredErrors, 0); assert.equal(s.ids.size, expectedCount); - assert.ok(isSorted(s.answerOrder), `${name} answered out of arrival order`); + assert.ok(isSorted(s.handledOrder), `${name} handled out of arrival order`); }); } - test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => { + test('window 2000 pressed past maxRunningHandlers (1000): the peer is throttled, the fast handler at window 500 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); + assert.ok(statsFor('dumb-w2000').peakRunning <= 1000); + assert.equal(statsFor('dumb-w500').arrived, 20_000); }); test('memory after the backlog drains back down is close to before either window run started', async () => { @@ -280,24 +282,24 @@ describe('S6 - idle peer, no enquire_link at all', () => { assert.ok(logEntries.some(entry => entry.message === 'linkTimers - closing an idle peer')); // teardown() (session.ts) is a raw close, not an unbind exchange - nothing further is on the // wire for this session, which the capture's histogram (findings/07-load.md) confirms. - assert.equal(statsFor('dumb-idle').answered, 1); + assert.equal(statsFor('dumb-idle').handled, 1); }); }); // The long soak: the longest run the time-box allows, fast handler, watched for anything that -// grows without bound (held messages, listeners, memory). Bounded by wall-clock, and asserting that -// arrived/answered stay in lockstep. +// grows without bound (running handlers, listeners, memory). Bounded by wall-clock, and asserting +// that arrived/handled stay in lockstep. describe('Long soak', () => { const SOAK_DURATION_MS = 300_000; - test('the longest run the time-box allows: every arrival answered, nothing duplicated, memory does not grow without bound', async () => { + test('the longest run the time-box allows: every arrival handled, nothing duplicated, memory does not grow without bound', async () => { await delay(SOAK_DURATION_MS); - // One more turn for a response mid-flight when the clock ran out to land, not a target count. + // One more turn for a message mid-flight when the clock ran out to land, not a target count. await waitFor(() => { const s = statsFor('dumb-soak'); - return s.arrived === s.answered ? true : undefined; + return s.arrived === s.handled ? true : undefined; }, 5000); const s = statsFor('dumb-soak'); @@ -305,9 +307,8 @@ describe('Long soak', () => { report(`soak reached: arrived=${String(s.arrived)} over ${String(SOAK_DURATION_MS / 1000)}s`); assert.ok(s.arrived > 0, 'dumb-soak never submitted anything'); - assert.equal(s.answered, s.arrived); + assert.equal(s.handled, s.arrived); assert.equal(s.duplicateIds, 0); - assert.equal(s.unansweredErrors, 0); const session = sessionByScenario.get('dumb-soak'); diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts index c6c9b92..684c1cb 100644 --- a/interop-tests/jasmin.test.ts +++ b/interop-tests/jasmin.test.ts @@ -1,18 +1,18 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import test, { after, describe } from 'node:test'; -import type { Dlr } from '../src/dlr.ts'; +import type { Dlr } from '../src/messages/dlr.ts'; import type { EncodingName } from '../src/defs/encodings.ts'; -import type { PduObject } from '../src/pdu.ts'; +import type { PduObject } from '../src/wire/pdu.ts'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; -import { ConcatReference } from '../src/udh.ts'; +import type { Sms } from '../src/messages/sms.ts'; +import { ConcatReference } from '../src/messages/udh.ts'; import { client } from '../src/client.ts'; import { closeAfter } from '../test/teardown.ts'; import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; -import { encodeMessage, splitMessage } from '../src/message.ts'; -import { submitSmParams } from '../src/send-sms.ts'; +import { encodeMessage, splitMessage } from '../src/messages/message.ts'; +import { submitSmParams } from '../src/messages/send-sms.ts'; const PEER_HOST = process.env.PEER_HOST ?? 'jasmin'; const PEER_PORT = Number(process.env.PEER_PORT ?? '2775'); @@ -86,6 +86,15 @@ 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: async sms => { + const variant = (sms.session.userData as { variant?: UpstreamVariant } | undefined)?.variant; + + if (variant) upstreamSms.push({ sms, variant }); + if (!sms.dlr) return; + + await delay(150); + await sms.sendDlr('DELIVERED'); + }, port: UPSTREAM_PORT, }); @@ -97,7 +106,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, from onSms, where authenticate() has long since run. session.on('incomingPduObj', pduObj => { if (!pduObj.cmdName.startsWith('bind_')) return; @@ -105,21 +114,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 { @@ -227,7 +221,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: 'GSM7', multipart }); const sent = await session.send({ cmdName: 'deliver_sm', params }); assert.equal(sent.err, undefined); @@ -247,7 +241,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, 'GSM7').buffer }, }, }); } @@ -379,8 +373,8 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac assert.ok(arrived, 'expected the HTTP-submitted message to arrive as submit_sm at our server()'); - // Our server() already answered (sendResp) and sent the DLR (sendDlr('DELIVERED')) from the - // shared session handler above - Jasmin's own DLR pipeline should throw the HTTP callback. + // Our server() already answered it and sent the DLR (sendDlr('DELIVERED')) from onSms above - + // Jasmin's own DLR pipeline should throw the HTTP callback. const msgidMatch = /Success "([^"]+)"/i.exec(sent.body); const msgid = msgidMatch?.[1]; @@ -397,15 +391,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 +409,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 +490,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 +511,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 +533,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..b21a18b 100644 --- a/interop-tests/jsmpp.test.ts +++ b/interop-tests/jsmpp.test.ts @@ -2,9 +2,10 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import test, { after, describe } from 'node:test'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; -import { PduRefusedError } from '../src/index.ts'; +import type { Sms } from '../src/messages/sms.ts'; +import { isCommand, PduRefusedError } from '../src/index.ts'; import { bareTlvHeader, pduBytes } from '../test/raw-pdus.ts'; +import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; const JSMPP_HOST = process.env.JSMPP_HOST ?? 'jsmpp:8080'; @@ -39,13 +40,20 @@ async function driver(path: string, params: Record = {}): Promis const allSms: { session: Session; sms: Sms }[] = []; const allSessionErrors: { err: Error; session: Session }[] = []; const bindPdus: Record[] = []; -/** Messages a test answers itself (a refusing status, or asserting on the response) - populate - * before triggering the submit that will carry this exact text, so the global auto-ack never runs. */ -const manualTexts = new Set(); +/** Messages refused from onRequest - populate before triggering the submit that carries the text. */ +const refusedTexts = new Set(); const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, + onRequest: async (session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || !refusedTexts.has(paramText(pduObj.params.short_message))) return false; + + await session.sendReturn(pduObj, 'ESME_RMSGQFUL'); + + return true; + }, + onSms: sms => { allSms.push({ session: sms.session, sms }); }, port: SMPP_PORT, }); @@ -58,10 +66,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 }); }); }); @@ -135,7 +139,6 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { const sms = await waitForSms(text, 15_000); - assert.equal(sms.answeredOnArrival, true); assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); }); @@ -151,7 +154,6 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { const sms = await waitForSms(text, 15_000); - assert.equal(sms.answeredOnArrival, true); assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); }); @@ -170,7 +172,6 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { const sms = await waitForSms(text); assert.equal(sms.message, text); - assert.equal(sms.answeredOnArrival, false); }); test('sar_* (target 3): one reassembled sms, each segment answered -', async () => { @@ -186,7 +187,6 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { const sms = await waitForSms(text, 15_000); - assert.equal(sms.answeredOnArrival, true); assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); // Neither ~130-char slice ever reached the application on its own. @@ -307,22 +307,18 @@ describe('S3 - known-but-unhandled and malformed commands (targets 1, 6)', () => }); describe('a refusing status is surfaced back to jsmpp', () => { - test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches jsmpp as a NegativeResponseException', async () => { + test('sendReturn(pduObj, "ESME_RMSGQFUL") from onRequest reaches jsmpp as a NegativeResponseException', async () => { await waitForSessions(1); const text = 'refuse-me'; - manualTexts.add(text); - - const submitted = driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' }); - const sms = await waitForSms(text); - - await sms.sendResp({ status: 'ESME_RMSGQFUL' }); + refusedTexts.add(text); - const result = await submitted; + const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' }); assert.equal(result.ok, true); assert.equal(result.refused, true); assert.equal(result.commandStatusHex, '0x' + (0x00000014).toString(16)); + assert.equal(allSms.some(entry => entry.sms.message === text), false); }); }); diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts index 0d911d0..a9e0c61 100644 --- a/interop-tests/kannel.test.ts +++ b/interop-tests/kannel.test.ts @@ -2,16 +2,17 @@ import assert from 'node:assert/strict'; import http from 'node:http'; import test, { after, describe } from 'node:test'; import type { MessageState } from '../src/defs/constants.ts'; -import type { Dlr } from '../src/dlr.ts'; +import type { Dlr } from '../src/messages/dlr.ts'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; -import { ConcatReference } from '../src/udh.ts'; +import type { Sms } from '../src/messages/sms.ts'; +import { ConcatReference } from '../src/messages/udh.ts'; import { consts } from '../src/defs/constants.ts'; import { detect, encodings } from '../src/defs/encodings.ts'; +import { isCommand } from '../src/wire/pdu.ts'; import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; -import { splitMessage } from '../src/message.ts'; -import { submitSmParams } from '../src/send-sms.ts'; +import { splitMessage } from '../src/messages/message.ts'; +import { submitSmParams } from '../src/messages/send-sms.ts'; // smsbox HTTP hosts, one per variant - all point at the same node:2775 SMPP server. const MAIN_SMSBOX = process.env.MAIN_SMSBOX ?? 'kannel-smsbox:13013'; @@ -141,9 +142,16 @@ function variantFromSystemId(systemId: string): Variant | undefined { return undefined; } +function variantOf(session: Session): Variant | undefined { + return (session.userData as { variant?: Variant } | undefined)?.variant; +} + const allSms: { sms: Sms; variant: Variant }[] = []; const allDlrs: { dlr: Dlr; variant: Variant }[] = []; const bindPdus: { params: Record; variant: Variant }[] = []; +const submitPdus: { text: string; variant: Variant }[] = []; +/** How long a submit_sm carrying this text is held before it is answered. */ +const holdMs = new Map(); const { err: serverErr, server: smpp } = await server({ authenticate: ({ password, systemId }) => { @@ -154,6 +162,20 @@ const { err: serverErr, server: smpp } = await server({ return variant ? { userData: { variant } } : false; }, idleTimeout: 40_000, + onRequest: async (_session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; + + const hold = holdMs.get(paramText(pduObj.params.short_message)); + + if (hold !== undefined) await delay(hold); + + return false; + }, + onSms: sms => { + const variant = variantOf(sms.session); + + if (variant) allSms.push({ sms, variant }); + }, port: SMPP_PORT, }); @@ -164,6 +186,14 @@ const smppServer = smpp; smppServer.on('session', session => { session.on('incomingPduObj', pduObj => { + if (isCommand(pduObj, 'submit_sm')) { + const variant = variantOf(session); + + if (variant) submitPdus.push({ text: paramText(pduObj.params.short_message), variant }); + + return; + } + if (!pduObj.cmdName.startsWith('bind_')) return; const variant = variantFromSystemId(paramText(pduObj.params.system_id)); @@ -171,14 +201,8 @@ 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; + const variant = variantOf(session); if (variant) allDlrs.push({ dlr, variant }); }); @@ -190,7 +214,7 @@ after(async () => { }); function sessionsFor(variant: Variant): Session[] { - return [...smppServer.sessions].filter(s => (s.userData as { variant?: Variant } | undefined)?.variant === variant); + return [...smppServer.sessions].filter(s => variantOf(s) === variant); } async function waitForSessions(variant: Variant, count: number, budget = 15_000): Promise { @@ -308,7 +332,6 @@ describe('S1 - MT from Kannel with delivery reports', () => { assert.equal(sms.to, '46709771337'); assert.equal(sms.dlr, true); - assert.equal((await sms.sendResp()).err, undefined); // dlr-mask bit 8: Kannel fires this off the submit_sm_resp alone, before any receipt. const submitAck = await waitForDlrCallback(sms.smsId, '8'); @@ -339,7 +362,6 @@ describe('long MT from Kannel', () => { const sms = await waitForSms('main', text, 15_000); assert.equal(sms.message, text); - assert.equal((await sms.sendResp()).err, undefined); }); test('UCS-2 text with 一 and an emoji reassembles whole', async () => { @@ -351,7 +373,6 @@ describe('long MT from Kannel', () => { const sms = await waitForSms('main', text, 15_000); assert.equal(sms.message, text); - assert.equal((await sms.sendResp()).err, undefined); }); }); @@ -365,7 +386,6 @@ describe('S11 - GSM extension characters', () => { const sms = await waitForSms('main', text); assert.equal(sms.message, text); - assert.equal((await sms.sendResp()).err, undefined); }); }); @@ -461,24 +481,22 @@ describe('S6 - wait-ack expiry and keepalive', () => { await waitForSessions('main', 1); const text = 's6-slow-resp'; - const before = allSms.filter(e => e.variant === 'main').length; + holdMs.set(text, 7000); await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' }); - const sms = await waitForSms('main', text); + const arrived = await waitFor(() => submitPdus.find(e => e.variant === 'main' && e.text === text)); - await delay(7000); - await sms.sendResp().catch(() => undefined); + assert.ok(arrived, 'the submit_sm never reached our server'); // wait-ack-expire defaults to 0x00 (disconnect/reconnect); reconnect-delay is 1s, so give it - // room to rebind and possibly resend the same submit_sm on the new session. - await delay(4000); + // room past the 7s hold to rebind and possibly resend the same submit_sm on the new session. + await delay(11_000); - const after = allSms.filter(e => e.variant === 'main' && e.sms.message === text); + const onRecord = allSms.filter(e => e.variant === 'main' && e.sms.message === text).length; - assert.ok(after.length >= 1, 'the original sms is still on record'); - // Recorded for findings, not asserted: whether a resend duplicated the sms is peer behaviour. - void before; + // Recorded for findings, not asserted: whether a resend put the sms on record is peer behaviour. + void onRecord; }); }); @@ -504,7 +522,6 @@ describe('iv33 variant - interface_version 0x33', () => { const sms = await waitForSms('iv33', text); - assert.equal((await sms.sendResp()).err, undefined); await waitForDlrCallback(sms.smsId, '8'); await sms.sendDlr('DELIVERED'); await waitForDlrCallback(sms.smsId, '1'); @@ -517,10 +534,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 @@ -565,7 +578,6 @@ describe('notrx variant - separate TX and RX binds', () => { const sms = await waitForSms('notrx', text); assert.equal(sms.session, tx); - assert.equal((await sms.sendResp()).err, undefined); }); test('a receipt built on the receiver bind reaches Kannel; the transmitter bind cannot carry one', async () => { @@ -598,7 +610,6 @@ describe('notrx variant - separate TX and RX binds', () => { const sms = await waitForSms('notrx', text); - assert.equal((await sms.sendResp()).err, undefined); await waitForDlrCallback(sms.smsId, '8'); const receiptDate = '2609051200'; diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts index 0415ce9..53a6e43 100644 --- a/interop-tests/php.test.ts +++ b/interop-tests/php.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test, { after, describe } from 'node:test'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import { paramText } from '../src/defs/types.ts'; import { isCommand, server } from '../src/index.ts'; @@ -48,6 +48,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, }); @@ -62,18 +67,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..81676b1 100644 --- a/interop-tests/python.test.ts +++ b/interop-tests/python.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test, { after, describe } from 'node:test'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import { encodings } from '../src/defs/encodings.ts'; import { paramText } from '../src/defs/types.ts'; import { isCommand, server } from '../src/index.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 () => { @@ -134,7 +133,6 @@ async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => bo type AckResult = { messageId?: string; status?: number }; -// A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so // /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards. async function waitForAck(name: string, sequence: number, budget = 8000): Promise { const found = await waitFor(async () => { @@ -149,7 +147,7 @@ async function waitForAck(name: string, sequence: number, budget = 8000): Promis } async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise { - const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII'; + const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'GSM7'; const buf = encodings[encName].encode(text); const sent = await session.send({ cmdName: 'deliver_sm', @@ -181,9 +179,7 @@ describe('S11 - encodings (python-smpplib)', () => { assert.equal(sent.ok, true, JSON.stringify(sent)); - const sms = await waitForSms('s11-basic', expectBasic + extensionChars); - - assert.equal((await sms.sendResp()).err, undefined); + await waitForSms('s11-basic', expectBasic + extensionChars); assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0); }); @@ -194,9 +190,7 @@ describe('S11 - encodings (python-smpplib)', () => { assert.equal(sent.ok, true, JSON.stringify(sent)); - const sms = await waitForSms('s11-ff', 'before-\f'); - - assert.equal((await sms.sendResp()).err, undefined); + await waitForSms('s11-ff', 'before-\f'); assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0); }); @@ -208,9 +202,7 @@ describe('S11 - encodings (python-smpplib)', () => { assert.equal(sent.ok, true, JSON.stringify(sent)); - const sms = await waitForSms('s11-latin1', text); - - assert.equal((await sms.sendResp()).err, undefined); + await waitForSms('s11-latin1', text); assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0); }); @@ -222,9 +214,7 @@ describe('S11 - encodings (python-smpplib)', () => { assert.equal(sent.ok, true, JSON.stringify(sent)); - const sms = await waitForSms('s11-ucs2', text); - - assert.equal((await sms.sendResp()).err, undefined); + await waitForSms('s11-ucs2', text); assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0); }); @@ -238,7 +228,6 @@ describe('S11 - encodings (python-smpplib)', () => { const sms = await waitForSms('s11-echo-gsm', 'seed'); - assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0); const session = sessionFor('s11-echo-gsm'); @@ -261,7 +250,6 @@ describe('S11 - encodings (python-smpplib)', () => { const sms = await waitForSms('s11-echo-ucs2', seed); - assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0); const session = sessionFor('s11-echo-ucs2'); @@ -288,7 +276,6 @@ describe('S11 - encodings (python-smpplib)', () => { const sms = await waitForSms('s11-quirk', '§'); - assert.equal((await sms.sendResp()).err, undefined); assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0); // Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads @@ -320,8 +307,6 @@ describe('S2 - long messages (python-smpplib, UDH)', () => { const sms = await waitForSms(name, text, 15_000); - assert.equal(sms.answeredOnArrival, true); - const results = sent.results as { messageId: string; status: number }[]; assert.equal(results.length, segments); @@ -382,8 +367,7 @@ describe('Refusals via onRequest', () => { await bindReader(name); - // Refused through onRequest, before reassembly and before the sms event, so this is answered - // without any sendResp() call - unlike every other submit in this file. + // Refused through onRequest, before reassembly and before onSms. const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST }); assert.equal(refused.ok, true, JSON.stringify(refused)); @@ -397,9 +381,7 @@ describe('Refusals via onRequest', () => { assert.equal(after1.ok, true, JSON.stringify(after1)); - const sms = await waitForSms(name, 'still works'); - - assert.equal((await sms.sendResp()).err, undefined); + await waitForSms(name, 'still works'); assert.equal((await waitForAck(name, after1.sequence as number)).status, 0); }); }); diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts index 8a08817..0a37e3c 100644 --- a/interop-tests/smppsim.test.ts +++ b/interop-tests/smppsim.test.ts @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import type { Dlr } from '../src/dlr.ts'; +import type { Dlr } from '../src/messages/dlr.ts'; import type { EncodingName } from '../src/defs/encodings.ts'; -import type { MessageDlr } from '../src/dlr-merger.ts'; -import type { PduObject } from '../src/pdu.ts'; +import type { MessageDlr } from '../src/messages/dlr-merger.ts'; +import type { PduObject } from '../src/wire/pdu.ts'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import { client } from '../src/client.ts'; import { closeAfter } from '../test/teardown.ts'; import { consts } from '../src/defs/constants.ts'; @@ -72,14 +72,6 @@ function collectDlrs(session: Session): Received[] { return received; } -function collectSms(session: Session): Sms[] { - const collected: Sms[] = []; - - session.on('sms', sms => { collected.push(sms); }); - - return collected; -} - const DLR_RETRY_BUDGET_MS = 3000; const DLR_MAX_ATTEMPTS = 10; @@ -148,7 +140,7 @@ async function sendUntilComplete( sms: Sms[], message: string, encoding?: EncodingName, -): Promise<{ reassembled: Sms; smsIds: string[] }> { +): Promise { for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) { const sent = await session.sendSms({ dlr: true, @@ -164,12 +156,12 @@ async function sendUntilComplete( const complete = await waitFor(() => { const allIntact = ids.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id))); - const reassembled = sms.find(s => s.message === message); + const reassembled = sms.some(s => s.message === message); - return allIntact && reassembled ? { reassembled } : undefined; + return allIntact && reassembled ? true : undefined; }, DLR_RETRY_BUDGET_MS); - if (complete) return { reassembled: complete.reassembled, smsIds: ids }; + if (complete) return ids; } throw new Error(`no attempt got both an intact DLR per segment and a loopback reassembly within ${String(DLR_MAX_ATTEMPTS)} tries`); @@ -202,16 +194,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 sms: Sms[] = []; + const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); const dlrs = collectDlrs(session); - const sms = collectSms(session); - - const { reassembled, smsIds } = await sendUntilComplete( + const smsIds = await sendUntilComplete( session, dlrs, sms, @@ -235,8 +226,6 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { assert.equal(received.dlr.receipt.id, id); assert.equal(received.dlr.receipt.err, '000'); } - - assert.equal((await reassembled.sendResp()).err, undefined); }); } }); @@ -588,14 +577,13 @@ 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 sms: Sms[] = []; + const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - const sms = collectSms(session); - await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO }); const received = await waitFor(() => sms.find(s => s.message === 'å ä ö')); @@ -605,14 +593,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => { }); test('UCS-2', async t => { - const { err, session } = await bind(PEER_HOST); + const sms: Sms[] = []; + const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - const sms = collectSms(session); - await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO }); const received = await waitFor(() => sms.find(s => s.message === 'ucs2 round trip')); @@ -622,14 +609,13 @@ 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 sms: Sms[] = []; + const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - const sms = collectSms(session); - await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO }); const received = await waitFor(() => sms.find(s => s.message === 'flash test')); @@ -640,13 +626,12 @@ 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 sms: Sms[] = []; + const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - - const sms = collectSms(session); const body = 'message class test'; const sent = await session.send({ @@ -668,13 +653,12 @@ 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 sms: Sms[] = []; + const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); assert.equal(err, undefined); assert.ok(session); closeAfter(t, session); - - const sms = collectSms(session); // 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..80cc3bb 100644 --- a/interop-tests/smscsim.test.ts +++ b/interop-tests/smscsim.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import type { Dlr } from '../src/dlr.ts'; +import type { Dlr } from '../src/messages/dlr.ts'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import { client } from '../src/client.ts'; import { closeAfter } from '../test/teardown.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', @@ -198,8 +196,6 @@ describe('smscsim - MO injection through the web UI', () => { assert.equal(sms.from, '46701113311'); assert.equal(sms.to, '46709771337'); assert.equal(sms.message, 'hello from the web UI'); - - assert.equal((await sms.sendResp()).err, undefined); }); }); diff --git a/src/client.ts b/src/client.ts index 6b7299c..ff97053 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,17 +1,19 @@ 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 './session/bind-direction.ts'; +import type { ReconnectOptions, SmsHandler } from './session/session-options.ts'; import type { SmppLog } from './log.ts'; -import type { SmsIdFormat } from './sms-id.ts'; +import type { SmsIdFormat } from './messages/sms-id.ts'; import type { Socket } from 'node:net'; export type { BindType }; -import { ReconnectLoop } from './reconnect-loop.ts'; +import { ReconnectLoop } from './session/reconnect-loop.ts'; import { Session } from './session.ts'; -import { checkSessionOptions } from './session-options.ts'; +import { checkSessionOptions } from './session/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 { errorFrom } from './error-from.ts'; import { guardedLog } from './log.ts'; /** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */ @@ -29,6 +31,8 @@ export type ClientOptions = { interfaceVersion?: number; log?: SmppLog; maxOutstanding?: number; + /** Every mobile-originated message. Without one, the SMSC's deliveries are refused. */ + onSms?: SmsHandler; password?: string; port?: number; reconnect?: ReconnectTuning | false; @@ -41,19 +45,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, @@ -97,7 +88,7 @@ function openSocket(options: ClientOptions): Promise> { try { sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port }); } catch (thrown: unknown) { - resolve({ err: thrown instanceof Error ? thrown : new Error(String(thrown)) }); + resolve({ err: errorFrom(thrown) }); return; } @@ -201,9 +192,11 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess return new Session({ enquireLinkInterval, - idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, + // Two silent probes, so the default idle timeout tracks a retuned interval. + 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..9b8a69f --- /dev/null +++ b/src/defaults.ts @@ -0,0 +1,33 @@ +/** Every option's default, in milliseconds where it is a time. README's option tables read from here. */ +export const defaults = { + bindType: 'transceiver', + connectTimeout: 10_000, + enquireLinkInterval: 20_000, + host: 'localhost', + /** Two probes have to go unanswered before a link counts as dead. */ + idleTimeout: 40_000, + interfaceVersion: 0x34, + maxDelay: 30_000, + maxOctets: 64 * 1024 * 1024, + maxOutstanding: 10, + maxReassembly: 1000, + minDelay: 1000, + password: 'pass', + port: 2775, + reassemblyTimeout: 300_000, + responseTimeout: 30_000, + shutdownTimeout: 5000, + systemId: '', + username: 'user', +} as const; + +/** The bounds that are not options: what a session holds for an application or a peer that never answers. */ +export const bounds = { + /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ + dlrMergeTimeout: 86_400_000, + /** An `onSms` handler still running after this is no longer counted or waited for. */ + handlerTimeout: 300_000, + maxDlrMerges: 1000, + /** Handlers running at once; a message arriving past it is refused so the peer retries. */ + maxRunningHandlers: 1000, +} as const; diff --git a/src/defs/constants.ts b/src/defs/constants.ts index 0ed155e..d7ea22e 100644 --- a/src/defs/constants.ts +++ b/src/defs/constants.ts @@ -1,6 +1,3 @@ -/** The version declared on the wire. The tables below cover 5.0, which is a superset of it. */ -export const defaultInterfaceVersion = 0x34; - /** Spec rule, not a preference: a peer declaring less than 3.4 is sent no optional parameters. */ export const optionalParamsMinVersion = 0x34; diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts index c454b02..054e3eb 100644 --- a/src/defs/encodings.ts +++ b/src/defs/encodings.ts @@ -1,4 +1,4 @@ -export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2'; +export type EncodingName = 'GSM7' | 'LATIN1' | 'UCS2'; export type Encoding = { decode: (buffer: Uint8Array) => string; @@ -103,7 +103,7 @@ const ucs2: Encoding = { }; export const encodings: Record = { - ASCII: ascii, + GSM7: 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.GSM7.match(value)) return 'GSM7'; if (encodings.LATIN1.match(value)) return 'LATIN1'; return 'UCS2'; @@ -163,14 +163,14 @@ 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' : 'GSM7'; } const alphabet = (dataCoding >> 2) & 0x03; if (alphabet === 0x01) return 'LATIN1'; - return alphabet === 0x02 ? 'UCS2' : 'ASCII'; + return alphabet === 0x02 ? 'UCS2' : 'GSM7'; } /** @@ -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' : 'GSM7'; } /** @@ -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> = { - ASCII: 0x00, + GSM7: 0x00, LATIN1: 0x03, UCS2: 0x08, }; 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; - -/** - * 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> { - 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; - 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(); - 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 { - 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/index.ts b/src/index.ts index 71fa973..d9cd7d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,9 +16,9 @@ export { objToPdu, pduReturn, pduToObj, -} from './pdu.ts'; +} from './wire/pdu.ts'; -export { maxPduLength, PduRefusedError } from './pdu-refusal.ts'; +export { maxPduLength, PduRefusedError } from './wire/pdu-refusal.ts'; export { bitCount, @@ -27,23 +27,24 @@ export { smppDate, smppTime, splitMessage, -} from './message.ts'; +} from './messages/message.ts'; -export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts'; -export { messageOctets } from './message-body.ts'; -export { concatOf } from './concat.ts'; -export { concatInfo } from './udh.ts'; -export { PduFramer } from './pdu-framer.ts'; -export { uuidv7 } from './uuid.ts'; +export { dlrFromPdu, parseReceipt, receiptCodes } from './messages/dlr.ts'; +export { messageOctets } from './messages/message-body.ts'; +export { concatOf } from './messages/concat.ts'; +export { concatInfo } from './messages/udh.ts'; +export { PduFramer } from './wire/pdu-framer.ts'; +export { uuidv7 } from './messages/uuid.ts'; -export type { BindType, ClientOptions } from './client.ts'; -export type { Dlr, Receipt } from './dlr.ts'; -export type { SendDlrResult, SendRespOptions, Sms } from './sms.ts'; -export type { Concat } from './concat.ts'; -export type { ConcatInfo } from './udh.ts'; +export type { BindType } from './session/bind-direction.ts'; +export type { ClientOptions } from './client.ts'; +export type { Dlr, Receipt } from './messages/dlr.ts'; +export type { SendDlrResult, Sms } from './messages/sms.ts'; +export type { Concat } from './messages/concat.ts'; +export type { ConcatInfo } from './messages/udh.ts'; export type { Result, VoidResult } from './result.ts'; export type { SmppLog } from './log.ts'; -export type { SmsIdFormat, SmsIdNotation } from './sms-id.ts'; +export type { SmsIdFormat, SmsIdNotation } from './messages/sms-id.ts'; export type { AuthenticateInput, AuthenticateResult, @@ -59,14 +60,15 @@ export type { SendSmsResult, SessionEvents, SessionOptions, + SmsHandler, } from './session.ts'; export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts'; export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts'; export type { ErrorName } from './defs/errors.ts'; -export type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; -export type { PduHeader } from './pdu-refusal.ts'; -export type { SplitOptions } from './message.ts'; +export type { PduObject, PduObjectInput, TlvInputs } from './wire/pdu.ts'; +export type { PduHeader } from './wire/pdu-refusal.ts'; +export type { SplitOptions } from './messages/message.ts'; export type { Tlv, TlvDefinition, TlvName, Tlvs } from './defs/tlvs.ts'; export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './defs/types.ts'; diff --git a/src/concat.ts b/src/messages/concat.ts similarity index 90% rename from src/concat.ts rename to src/messages/concat.ts index b56f421..4b2f74b 100644 --- a/src/concat.ts +++ b/src/messages/concat.ts @@ -1,9 +1,9 @@ import type { ConcatInfo } from './udh.ts'; -import type { PduObject } from './pdu.ts'; +import type { PduObject } from '../wire/pdu.ts'; import { concatInfo } from './udh.ts'; -import { hasUdh } from './defs/constants.ts'; +import { hasUdh } from '../defs/constants.ts'; import { messageOctets } from './message-body.ts'; -import { paramNumber } from './defs/types.ts'; +import { paramNumber } from '../defs/types.ts'; /** Where a segment sits in its message, and what ties it to the rest of that message. */ export type Concat = ConcatInfo & { diff --git a/src/dlr-merger.ts b/src/messages/dlr-merger.ts similarity index 93% rename from src/dlr-merger.ts rename to src/messages/dlr-merger.ts index 0c20cae..0ef448f 100644 --- a/src/dlr-merger.ts +++ b/src/messages/dlr-merger.ts @@ -1,6 +1,6 @@ import type { Dlr } from './dlr.ts'; -import type { MessageState } from './defs/constants.ts'; -import type { SmppLog } from './log.ts'; +import type { MessageState } from '../defs/constants.ts'; +import type { SmppLog } from '../log.ts'; import { ExpiringGroups } from './expiring-groups.ts'; import { parseSegmentId } from './sms-id.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: its group goes, and a later message handed the same ids merges nothing. */ + 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/dlr.ts b/src/messages/dlr.ts similarity index 95% rename from src/dlr.ts rename to src/messages/dlr.ts index 27ab4dd..6fcb33b 100644 --- a/src/dlr.ts +++ b/src/messages/dlr.ts @@ -1,12 +1,12 @@ -import type { MessageState } from './defs/constants.ts'; -import type { TlvValue } from './defs/types.ts'; -import type { PduObject } from './pdu.ts'; +import type { MessageState } from '../defs/constants.ts'; +import type { TlvValue } from '../defs/types.ts'; +import type { PduObject } from '../wire/pdu.ts'; import type { SmsIdFormat } from './sms-id.ts'; -import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts'; -import { encodings } from './defs/encodings.ts'; +import { consts, constsById, hasUdh, messageTypeOf } from '../defs/constants.ts'; +import { encodings } from '../defs/encodings.ts'; import { messageOctets } from './message-body.ts'; import { normaliseSmsId } from './sms-id.ts'; -import { paramNumber, paramText } from './defs/types.ts'; +import { paramNumber, paramText } from '../defs/types.ts'; import { udhLength } from './udh.ts'; /** diff --git a/src/expiring-groups.ts b/src/messages/expiring-groups.ts similarity index 100% rename from src/expiring-groups.ts rename to src/messages/expiring-groups.ts diff --git a/src/message-body.ts b/src/messages/message-body.ts similarity index 92% rename from src/message-body.ts rename to src/messages/message-body.ts index a244b8f..cb07d66 100644 --- a/src/message-body.ts +++ b/src/messages/message-body.ts @@ -1,4 +1,4 @@ -import type { PduObject } from './pdu.ts'; +import type { PduObject } from '../wire/pdu.ts'; /** * The user data, wherever the peer put it. SMPP 3.4 5.3.2.32 carries up to 64 KB in diff --git a/src/message.ts b/src/messages/message.ts similarity index 95% rename from src/message.ts rename to src/messages/message.ts index 0665019..b8fb548 100644 --- a/src/message.ts +++ b/src/messages/message.ts @@ -1,7 +1,7 @@ -import type { Result } from './result.ts'; -import type { EncodingName } from './defs/encodings.ts'; -import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts'; -import { hasUdh } from './defs/constants.ts'; +import type { Result } from '../result.ts'; +import type { EncodingName } from '../defs/encodings.ts'; +import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from '../defs/encodings.ts'; +import { hasUdh } from '../defs/constants.ts'; import { udhLength } from './udh.ts'; /** A single SMS carries 1120 bits, whatever the alphabet. */ @@ -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 = { ASCII: 153, LATIN1: 134, UCS2: 134 }; +const segmentUnits: Record = { GSM7: 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 === 'GSM7' ? encoded.length * 7 : encoded.length * 8; } /** diff --git a/src/reassembly.ts b/src/messages/reassembly.ts similarity index 96% rename from src/reassembly.ts rename to src/messages/reassembly.ts index 4f3d2c5..ede9553 100644 --- a/src/reassembly.ts +++ b/src/messages/reassembly.ts @@ -1,11 +1,12 @@ import type { Concat } from './concat.ts'; -import type { PduObject } from './pdu.ts'; -import type { SmppLog } from './log.ts'; +import type { PduObject } from '../wire/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'; -import { paramNumber, paramText } from './defs/types.ts'; +import { paramNumber, paramText } from '../defs/types.ts'; import { uuidv7 } from './uuid.ts'; /** A concatenated message given up on, whose segments the peer has already been answered for. */ @@ -42,8 +43,6 @@ export type Collected = whole?: PduObject[] | undefined; }; -export const defaultMaxOctets = 64 * 1024 * 1024; - type Group = { parts: Map; 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.maxOctets; this.groups = new ExpiringGroups({ max: options.max, maxWeight: this.maxOctets, diff --git a/src/retained-pdu.ts b/src/messages/retained-pdu.ts similarity index 91% rename from src/retained-pdu.ts rename to src/messages/retained-pdu.ts index c46e2c9..16f0447 100644 --- a/src/retained-pdu.ts +++ b/src/messages/retained-pdu.ts @@ -1,6 +1,6 @@ -import type { ParamValue } from './defs/types.ts'; -import type { PduObject } from './pdu.ts'; -import { tlvOctets } from './defs/types.ts'; +import type { ParamValue } from '../defs/types.ts'; +import type { PduObject } from '../wire/pdu.ts'; +import { tlvOctets } from '../defs/types.ts'; /** Wire reads hand back views, so retaining one PDU would pin the whole chunk it arrived in. */ export function detach(pduObj: PduObject): PduObject { diff --git a/src/send-sms.ts b/src/messages/send-sms.ts similarity index 95% rename from src/send-sms.ts rename to src/messages/send-sms.ts index 2e70932..af4ce6b 100644 --- a/src/send-sms.ts +++ b/src/messages/send-sms.ts @@ -1,15 +1,15 @@ -import type { EncodingName, Unencodable } from './defs/encodings.ts'; -import type { ParamValue } from './defs/types.ts'; -import type { SubmitMessagingMode } from './defs/constants.ts'; -import type { PduObject, PduObjectInput } from './pdu.ts'; -import type { Result } from './result.ts'; -import type { SmppLog } from './log.ts'; +import type { EncodingName, Unencodable } from '../defs/encodings.ts'; +import type { ParamValue } from '../defs/types.ts'; +import type { SubmitMessagingMode } from '../defs/constants.ts'; +import type { PduObject, PduObjectInput } from '../wire/pdu.ts'; +import type { Result } from '../result.ts'; +import type { SmppLog } from '../log.ts'; import type { SmsIdNotation } from './sms-id.ts'; import { UnansweredError } from './unanswered-error.ts'; -import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts'; -import { cstring, paramText } from './defs/types.ts'; -import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts'; -import { namedValue } from './error-from.ts'; +import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from '../defs/constants.ts'; +import { cstring, paramText } from '../defs/types.ts'; +import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from '../defs/encodings.ts'; +import { namedValue } from '../error-from.ts'; import { normaliseSmsId } from './sms-id.ts'; import { maxSegments, smppTime, splitMessage } from './message.ts'; diff --git a/src/sms-id.ts b/src/messages/sms-id.ts similarity index 95% rename from src/sms-id.ts rename to src/messages/sms-id.ts index 8a558b6..f0f3d87 100644 --- a/src/sms-id.ts +++ b/src/messages/sms-id.ts @@ -1,5 +1,5 @@ -import type { CommandName } from './defs/commands.ts'; -import type { ParamValue } from './defs/types.ts'; +import type { CommandName } from '../defs/commands.ts'; +import type { ParamValue } from '../defs/types.ts'; const notations = { decimal: { digits: /^[0-9]+$/, prefix: '' }, diff --git a/src/messages/sms.ts b/src/messages/sms.ts new file mode 100644 index 0000000..37c4018 --- /dev/null +++ b/src/messages/sms.ts @@ -0,0 +1,152 @@ +import type { MessageState } from '../defs/constants.ts'; +import type { PduObject, PduObjectInput, TlvInputs } from '../wire/pdu.ts'; +import type { Result } from '../result.ts'; +import type { Session } from '../session.ts'; +import { UnansweredError } from './unanswered-error.ts'; +import { consts } from '../defs/constants.ts'; +import { decodeSegments } from './reassembly.ts'; +import { messageClassOf } from '../defs/encodings.ts'; +import { paramText } from '../defs/types.ts'; +import { receiptCodes, transientStates } from './dlr.ts'; +import { smppDate } from './message.ts'; +import { segmentId } from './sms-id.ts'; + +/** `pduObjs` holds what the peer took, so a partial failure names what is already receipted. */ +export type SendDlrResult = { + err?: Error; + pduObjs: PduObject[]; + /** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */ + unanswered: number; +}; + +/** A received SMS, already answered `ESME_ROK` under `smsId`. A multipart message carries every segment's PDU. */ +export type Sms = { + dlr: boolean; + /** GSM 03.38 message class 0: shown on arrival and not stored. */ + flash: boolean; + from: string; + message: string; + pduObjs: PduObject[]; + /** Sends a delivery report back to the sender. Defaults to DELIVERED. */ + sendDlr: (status?: MessageState) => Promise; + session: Session; + /** The id the peer was answered with: the base of `-` where it arrived in segments. */ + smsId: string; + submitTime: Date; + to: string; +}; + +export type SmsInput = { + pduObjs: PduObject[]; + session: Session; + smsId: string; +}; + +/** A receipt reports on a message this session took, so a shutdown's drain lets it out. */ +export type SendReceipt = (input: PduObjectInput) => Promise>; + +/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ +const immediateDisplayClass = 0; + +export function createSms(input: SmsInput, sendReceipt: SendReceipt): Sms { + const first = input.pduObjs[0]; + const registered = first?.params.registered_delivery; + const dataCoding = first?.params.data_coding; + const sms: Sms = { + dlr: typeof registered === 'number' && registered !== 0, + flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass, + from: paramText(first?.params.source_addr), + message: decodeSegments(input.pduObjs), + pduObjs: input.pduObjs, + sendDlr: status => sendDlr(sms, sendReceipt, status), + session: input.session, + smsId: input.smsId, + submitTime: new Date(), + to: paramText(first?.params.destination_addr), + }; + + return sms; +} + +/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ +function receiptText(sms: Sms, smsId: string, status: MessageState): string { + const delivered = status === 'DELIVERED'; + const failed = !delivered && !transientStates.includes(status); + + return [ + `id:${smsId}`, + 'sub:001', + `dlvrd:${delivered ? '001' : '000'}`, + `submit date:${smppDate(sms.submitTime)}`, + `done date:${smppDate(new Date())}`, + `stat:${receiptCodes[status]}`, + `err:${failed ? '001' : '000'}`, + 'text:', + ].join(' '); +} + +function receiptTlvs(smsId: string, status: MessageState): TlvInputs { + return { + message_state: { tagValue: consts.MESSAGE_STATE[status] }, + receipted_message_id: { tagValue: smsId }, + }; +} + +function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult { + const pduObjs: PduObject[] = []; + let failure: Error | undefined; + let unanswered = 0; + + for (const one of sent) { + if (one.err) { + if (one.err instanceof UnansweredError) unanswered++; + + failure ??= one.err; + } else if (one.pduObj.cmdStatus === 'ESME_ROK') { + pduObjs.push(one.pduObj); + } else { + const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId); + + failure ??= new Error(`deliver_sm refused by the peer: ${refusal}`); + } + } + + return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered }; +} + +async function sendDlr( + sms: Sms, + sendReceipt: SendReceipt, + status: MessageState = 'DELIVERED', +): Promise { + const { session } = sms; + + if (!session.bindAllows('deliver_sm')) { + return { + err: new Error('A transmitter-bound session does not carry deliver_sm'), + pduObjs: [], + unanswered: 0, + }; + } + + const total = sms.pduObjs.length; + // Together, not one after a response: the receipt is one message's, and goes out as one. + const sent = await Promise.all(sms.pduObjs.map((_segment, index) => { + const smsId = segmentId(sms.smsId, index, total); + + return sendReceipt({ + cmdName: 'deliver_sm', + params: { + destination_addr: sms.from, + esm_class: transientStates.includes(status) + ? consts.ESM_CLASS.INTERMEDIATE_DELIVERY + : consts.ESM_CLASS.MC_DELIVERY_RECEIPT, + short_message: receiptText(sms, smsId, status), + source_addr: sms.to, + }, + ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), + }); + })); + + return collectReceipt(sent); +} diff --git a/src/udh.ts b/src/messages/udh.ts similarity index 100% rename from src/udh.ts rename to src/messages/udh.ts diff --git a/src/unanswered-error.ts b/src/messages/unanswered-error.ts similarity index 100% rename from src/unanswered-error.ts rename to src/messages/unanswered-error.ts diff --git a/src/uuid.ts b/src/messages/uuid.ts similarity index 100% rename from src/uuid.ts rename to src/messages/uuid.ts diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts deleted file mode 100644 index a0adf24..0000000 --- a/src/outgoing-requests.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { LinkLife } from './link-life.ts'; -import type { PduObject, PduObjectInput } from './pdu.ts'; -import type { PduTransport } from './pdu-transport.ts'; -import type { Result, VoidResult } from './result.ts'; -import type { SendOptions } from './session-options.ts'; -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 { objToPdu } from './pdu.ts'; - -export type OutgoingRequestsOptions = { - link: LinkLife; - log: SmppLog; - maxOutstanding: number; - responseTimeout: number; - transport: PduTransport; -}; - -/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */ -type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean }; - -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; - private readonly log: SmppLog; - private readonly pending: PendingRequests; - private readonly responseTimeout: number; - private readonly transport: PduTransport; - private readonly window: SendWindow; - - constructor(options: OutgoingRequestsOptions) { - this.link = options.link; - this.log = options.log; - this.pending = new PendingRequests(options.log); - this.responseTimeout = options.responseTimeout; - this.transport = options.transport; - this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); - } - - canCarry(): boolean { - return this.link.isUp() && !this.transport.sock.destroyed; - } - - /** The link is gone, and every answer still owed on it with it. */ - linkLost(): void { - this.pending.settleAll(new Error('Session closed before a response arrived')); - } - - /** Hands a response to the request waiting for it. False means nothing was. */ - deliver(pduObj: PduObject): boolean { - return this.pending.deliver(pduObj); - } - - /** A response the codec refused settles its request instead of leaving it to time out. */ - settleRefused(seqNr: number, err: Error): void { - this.pending.settle(seqNr, { err }); - } - - request(input: PduObjectInput, options: SendOptions): Promise> { - // 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> { - const refused = this.refuse(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); - } - - const waitForLink = this.link.hold(options.signal); - - for (;;) { - const held = await waitForLink(); - - if (held.err) return { err: held.err }; - - const slot = await this.window.acquire(options.signal); - - if (slot.err) return { err: slot.err }; - - const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); - - if (!this.retriesOnNextLink(attempt)) return attempt.result; - } - } - - /** Straight onto the current link, for what has to go out either way. */ - async requestOnCurrentLink( - input: PduObjectInput, - options: SendOptions = {}, - ): Promise> { - return (await this.attempt(input, options)).result; - } - - /** Waits out the requests already on the wire, and says how many never finished. */ - async drain(timeout: number, signal: AbortSignal | undefined): Promise { - const unfinished = await this.window.idle(timeout, signal); - - if (unfinished === 0) return {}; - - this.log.warn('outgoingRequests - shutting down with requests unfinished', { timeout, unfinished }); - - return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; - } - - /** Nothing reached the socket, so the next link carries it. */ - private retriesOnNextLink(attempt: Attempt): boolean { - // Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins. - return attempt.retryOnNextLink && this.link.awaitsNextLink(); - } - - /** Why a request cannot go out at all, as opposed to not yet. */ - 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 async attempt(input: PduObjectInput, options: SendOptions): Promise { - // pending.wait() alone settles the caller while the request still goes out to the peer. - if (options.signal?.aborted === true) { - return { result: { err: abortedBeforeSend() }, retryOnNextLink: false }; - } - - const seqNr = this.pending.nextSeqNr(); - const built = objToPdu({ ...input, seqNr }); - - if (built.err) return { result: { err: built.err }, retryOnNextLink: false }; - - const response = this.pending.wait(seqNr, { - signal: options.signal, - timeout: this.responseTimeout, - }); - const written = this.transport.write(built.buffer); - - if (written.err) { - this.pending.settle(seqNr, { err: written.err }); - - return { result: { err: written.err }, retryOnNextLink: true }; - } - - const answered = await response; - - // It went out, so a failure now means the peer may have taken it and the answer was the loss. - return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false }; - } -} diff --git a/src/server.ts b/src/server.ts index 2b9aefe..742b1c9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,15 +1,17 @@ -import type { BindType, CloseOptions, OnRequest } from './session-options.ts'; -import type { PduObject, TlvInputs } from './pdu.ts'; +import type { BindType } from './session/bind-direction.ts'; +import type { CloseOptions, OnRequest, SmsHandler } from './session/session-options.ts'; +import type { PduObject, TlvInputs } from './wire/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 './session/bind-direction.ts'; +import { checkSessionOptions } from './session/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; + /** Every message a bound peer submits, on any session. Without one, submissions are refused. */ + onSms?: SmsHandler; 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 = (...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.ts b/src/session.ts index 1fd9b46..f6e7805 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,30 +1,33 @@ +import type { BindType, LinkEnd, SessionBind } from './session/bind-direction.ts'; import type { ErrorName } from './defs/errors.ts'; -import type { MessageDlr } from './dlr-merger.ts'; +import type { Lane } from './session/outgoing-requests.ts'; +import type { MessageDlr } from './messages/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 { PduObject, PduObjectInput, TlvInputs } from './wire/pdu.ts'; +import type { PduRefusedError } from './wire/pdu-refusal.ts'; +import type { CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions, SmsHandler } from './session/session-options.ts'; import type { Result, VoidResult } from './result.ts'; -import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; +import type { SendSmsOptions, SendSmsResult } from './messages/send-sms.ts'; import type { SmppLog } from './log.ts'; import type { Socket } from 'node:net'; -import { DlrMerger } from './dlr-merger.ts'; +import { DlrMerger } from './messages/dlr-merger.ts'; import { EventEmitter } from 'node:events'; -import { IncomingRequests } from './incoming-requests.ts'; -import { LinkLife } from './link-life.ts'; -import { LinkTimers } from './link-timers.ts'; -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 { IncomingRequests } from './session/incoming-requests.ts'; +import { LinkLife } from './session/link-life.ts'; +import { LinkTimers } from './session/link-timers.ts'; +import { OutgoingRequests } from './session/outgoing-requests.ts'; +import { PduTransport } from './session/pdu-transport.ts'; +import { ReconnectLoop } from './session/reconnect-loop.ts'; +import { bindCarries, checkedBind, linkCommands } from './session/bind-direction.ts'; +import { bounds, defaults } from './defaults.ts'; +import { leftOf } from './session/idle-waiters.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 { isResp, objToPdu, pduReturn } from './wire/pdu.ts'; +import { refusalAnswer } from './wire/pdu-refusal.ts'; import { guardedLog } from './log.ts'; -import { submitSms, unsent } from './send-sms.ts'; -import { ConcatReference } from './udh.ts'; +import { submitSms, unsent } from './messages/send-sms.ts'; +import { ConcatReference } from './messages/udh.ts'; export type { CloseOptions, @@ -35,13 +38,23 @@ export type { SendSmsResult, SessionEvents, SessionOptions, + SmsHandler, }; export type { BindType }; -export { bindCommands, defaultSystemId }; /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ type SessionListener = (...args: SessionEvents[K]) => unknown; +/** What a drain could not wait out, in the words `close()` reports. */ +function drainError(handlers: number, requests: number): VoidResult { + const lost = [ + ...(handlers > 0 ? [`${String(handlers)} handler(s) still running`] : []), + ...(requests > 0 ? [`${String(requests)} request(s) unfinished`] : []), + ]; + + return lost.length === 0 ? {} : { err: new Error(`Shut down with ${lost.join('; ')}`) }; +} + export class Session extends EventEmitter { declare addListener: (event: K, listener: SessionListener) => this; declare off: (event: K, listener: SessionListener) => this; @@ -93,13 +106,11 @@ export class Session extends EventEmitter { 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); } @@ -108,7 +119,7 @@ export class Session extends EventEmitter { this.log = guardedLog(options.log); this.options = options; - this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout }); + this.dlrMerger = new DlrMerger({ log: this.log, max: bounds.maxDlrMerges, timeout: bounds.dlrMergeTimeout }); this.reconnectLoop = this.loopFor(options.reconnect); const responseTimeout = options.responseTimeout ?? defaults.responseTimeout; @@ -119,8 +130,7 @@ export class Session extends EventEmitter { 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({ @@ -132,19 +142,19 @@ export class Session extends EventEmitter { }); this.incoming = new IncomingRequests({ 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.request(input, {}, 'receipt'), session: this, smsIdFormat: options.smsIdFormat, systemId: options.systemId, }); - this.resetTimers(); + this.timers.reset(); } /** Replaced on reconnect, so hold the session rather than this. */ @@ -162,13 +172,16 @@ export class Session extends EventEmitter { return this.bind?.peerVersion; } - /** Records a bind this link accepted or had accepted, until the next one. */ + /** Records a bind this link accepted or had accepted, which is what lets it carry requests. */ bound(bindType: string, declaredVersion: unknown): VoidResult { const checked = checkedBind(bindType, declaredVersion); - if (!checked.err) this.bind = checked.bind; + if (checked.err) return { err: checked.err }; + + this.bind = checked.bind; + this.link.open(); - return checked.err ? { err: checked.err } : {}; + return {}; } /** Whether this session's bind direction carries a command. Consulted by the library's senders. */ @@ -183,7 +196,11 @@ export class Session extends EventEmitter { /** Sends a request and resolves with the peer's response. */ send(input: PduObjectInput, options: SendOptions = {}): Promise> { - return this.outgoing.request(input, options); + return this.request(input, options, linkCommands.includes(input.cmdName) ? 'link' : 'message'); + } + + private request(input: PduObjectInput, options: SendOptions, lane: Lane): Promise> { + return this.outgoing.request(input, options, lane); } /** Answers a request the peer sent us. Responses are never waited on. */ @@ -237,7 +254,7 @@ export class Session extends EventEmitter { const drained = await this.drain(undefined); const wasOpen = this.link.isAttached(); const sent = wasOpen - ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' }) + ? await this.request({ cmdName: 'unbind' }, {}, 'link') : { err: new Error('Session is closed') }; const closedOnUnbind = wasOpen && !this.link.isAttached(); @@ -246,10 +263,7 @@ export class Session extends EventEmitter { 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. - */ + /** Closes for good: refuses new sends, drains, then tears down whatever is left. Never reconnects. */ async close(options: CloseOptions = {}): Promise { const drained = await this.drain(options.signal); @@ -261,7 +275,7 @@ export class Session extends EventEmitter { private transportFor(sock: Socket): PduTransport { return new PduTransport({ log: this.log, - onClose: () => { this.onClose(); }, + onClose: () => { this.linkLost(); }, onData: chunk => { this.onData(chunk); }, onError: err => { this.emit('sessionError', err); }, onFramed: pdu => { this.emit('incomingPdu', pdu); }, @@ -269,7 +283,7 @@ export class Session extends EventEmitter { onRefused: refused => { this.refuse(refused); }, onUnreadable: err => { this.emit('sessionError', err); - this.teardown(); + this.linkLost(); }, }, sock); } @@ -290,37 +304,35 @@ export class Session extends EventEmitter { sock: Socket, bind: (session: Session) => Promise, ): Promise { - this.attach(sock); + 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(); + if (!this.link.retrying() || !this.link.isUp()) { + this.linkLost(); return { err: new Error('Session closed while it was coming back up') }; } - this.resetTimers(); - this.link.open(); + this.timers.reset(); this.log.info('session - reconnected'); this.emit('reconnected'); 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. */ + /** + * Stops new sends, then waits up to `shutdownTimeout` for the handlers still running and the + * requests already on the wire. A receipt a handler sends is a request like any other by then. + */ private async drain(signal: AbortSignal | undefined): Promise { this.stop(); @@ -329,37 +341,19 @@ export class Session extends EventEmitter { 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); + const handlers = await this.incoming.idle(timeout, signal); + const requests = await this.outgoing.idle(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 (!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; - } + if (handlers > 0 || requests > 0) { + this.log.warn('session - shutting down with work unfinished', { handlers, requests, timeout }); + } - /** The session is over now, drained or not. Nothing brings it back. */ - private end(): void { - this.stop(); - this.teardown(); - this.dlrMerger.clear(); - this.emitClose(); + return drainError(handlers, requests); } /** No new sends, and no link after this one. */ @@ -368,31 +362,51 @@ export class Session extends EventEmitter { this.reconnectLoop?.stop(); } - private emitClose(): void { - if (!this.link.end()) return; + /** Drops the attached socket and everything that only that socket could carry. False when none was attached. */ + private dropSocket(): boolean { + if (!this.link.drop()) return false; this.outgoing.linkLost(); - this.emit('close'); + this.timers.clear(); + this.incoming.linkLost(); + this.sock.destroy(); + + return true; } - private teardown(): void { - const lost = this.link.drop(); + /** The socket died, went quiet, or carried a stream that cannot be read. The next link follows, or the end. */ + private linkLost(): void { + // Read first: a `disconnected` listener may close() the session, and the drop still reports as one. + const retrying = this.link.retrying(); - if (!lost) return; + if (!this.dropSocket()) return; - this.outgoing.linkLost(); - this.timers.clear(); - this.incoming.clear(); - this.sock.destroy(); + if (!retrying) { + this.end(); - // `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(); + return; + } + + this.emit('disconnected'); + this.reconnectLoop?.schedule(); + } + + /** The session is over, drained or not. Nothing brings it back. */ + private end(): void { + this.stop(); + this.dropSocket(); + + if (!this.link.end()) return; + + this.dlrMerger.clear(); + this.incoming.release(); + this.emit('close'); } private onData(chunk: Buffer): void { this.emit('data', chunk); - this.resetTimers(); + + if (this.link.isAttached()) this.timers.reset(); } private dispatch(pduObj: PduObject): void { @@ -405,7 +419,7 @@ export class Session extends EventEmitter { } this.emit('incomingPduObj', pduObj); - // Every application hook and listener reached from an incoming PDU funnels through here. + // Every application hook reached from an incoming PDU funnels through here. void this.incoming.handle(pduObj).catch((thrown: unknown) => { const err = errorFrom(thrown); @@ -433,21 +447,4 @@ export class Session extends EventEmitter { 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/session/bind-direction.ts b/src/session/bind-direction.ts new file mode 100644 index 0000000..b5c3e94 --- /dev/null +++ b/src/session/bind-direction.ts @@ -0,0 +1,76 @@ +import type { Result } from '../result.ts'; +import { namedValue } from '../error-from.ts'; + +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'; + +const bindTypes: readonly BindType[] = ['receiver', 'transceiver', 'transmitter']; + +export const bindCommands: readonly string[] = bindTypes.map(bindType => `bind_${bindType}`); + +/** The commands that manage the link itself, which go out on it as it is: unbound, or draining. */ +export const linkCommands: readonly string[] = [...bindCommands, 'enquire_link', 'unbind']; + +function isBindType(value: unknown): value is BindType { + return typeof value === 'string' && bindTypes.some(bindType => bindType === value); +} + +export function bindTypeFromCommand(cmdName: string): BindType | undefined { + const bindType = cmdName.startsWith('bind_') ? cmdName.slice('bind_'.length) : undefined; + + return isBindType(bindType) ? bindType : undefined; +} + +/** + * Which message-carrying command an inbound one stands in for. Every command but `data_sm` names + * its own direction; that one travels either way, so the end it arrived at is what says. + */ +export function standsInFor(cmdName: string, linkEnd: LinkEnd): string { + if (cmdName !== 'data_sm') return cmdName; + + return linkEnd === 'smsc' ? 'submit_sm' : 'deliver_sm'; +} + +/** + * Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a + * transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that + * has not bound carries everything, since nothing has declared a direction yet. + */ +export function bindCarries( + bindType: BindType | undefined, + cmdName: string, + linkEnd: LinkEnd, +): boolean { + const carried = standsInFor(cmdName, linkEnd); + + if (bindType === 'receiver') return carried !== 'submit_sm'; + if (bindType === 'transmitter') return carried !== 'deliver_sm'; + + return true; +} + +/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */ +export const undeclaredInterfaceVersion = 0x00; + +export type SessionBind = { as: BindType; peerVersion: number }; + +function 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/idle-waiters.ts b/src/session/idle-waiters.ts similarity index 100% rename from src/idle-waiters.ts rename to src/session/idle-waiters.ts diff --git a/src/incoming-requests.ts b/src/session/incoming-requests.ts similarity index 59% rename from src/incoming-requests.ts rename to src/session/incoming-requests.ts index 51aeec7..1b0804b 100644 --- a/src/incoming-requests.ts +++ b/src/session/incoming-requests.ts @@ -1,23 +1,24 @@ -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 { PduObject } from './pdu.ts'; -import type { VoidResult } from './result.ts'; -import type { Session } from './session.ts'; -import type { SmppLog } from './log.ts'; -import type { SmsIdFormat } from './sms-id.ts'; -import { HeldMessages } from './held-messages.ts'; -import { Reassembler } from './reassembly.ts'; -import { bindCommands, defaults, standsInFor } from './session-options.ts'; -import { concatOf } from './concat.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'; +import type { Concat } from '../messages/concat.ts'; +import type { DlrMerger } from '../messages/dlr-merger.ts'; +import type { ErrorName } from '../defs/errors.ts'; +import type { LostGroup, Refusal } from '../messages/reassembly.ts'; +import type { OnRequest, SmsHandler } from './session-options.ts'; +import type { PduObject } from '../wire/pdu.ts'; +import type { SendReceipt } from '../messages/sms.ts'; +import type { Session } from '../session.ts'; +import type { SmppLog } from '../log.ts'; +import type { SmsIdFormat } from '../messages/sms-id.ts'; +import { Reassembler } from '../messages/reassembly.ts'; +import { RunningHandlers } from './running-handlers.ts'; +import { bindCommands, standsInFor } from './bind-direction.ts'; +import { bounds, defaults } from '../defaults.ts'; +import { concatOf } from '../messages/concat.ts'; +import { createSms } from '../messages/sms.ts'; +import { detach } from '../messages/retained-pdu.ts'; +import { dlrFromPdu } from '../messages/dlr.ts'; +import { respIdParams, segmentId } from '../messages/sms-id.ts'; +import { respNameFor } from '../defs/commands.ts'; +import { uuidv7 } from '../messages/uuid.ts'; /** Asks the peer to keep the message and retry. */ function throttledStatus(carriedAs: string): ErrorName { @@ -45,45 +46,42 @@ const lostReasons: Record = { export type IncomingRequestsOptions = { dlrMerger: DlrMerger; - link: LinkLife; log: SmppLog; maxOctets?: number | undefined; maxReassembly?: number | undefined; onRequest?: OnRequest | undefined; + onSms?: SmsHandler | undefined; reassemblyTimeout?: number | undefined; - sendPastDrain: HeldMessagesOptions['sendPastDrain']; + sendReceipt: SendReceipt; session: Session; smsIdFormat?: SmsIdFormat | undefined; systemId?: string | undefined; }; -/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ +/** Every request the peer sends: the answer each one gets, and the message or report it becomes. */ export class IncomingRequests { private readonly dlrMerger: DlrMerger; - private readonly held: HeldMessages; - private readonly link: LinkLife; + private readonly handlers: RunningHandlers; private readonly log: SmppLog; private readonly onRequest: OnRequest | undefined; + private readonly onSms: SmsHandler | undefined; private readonly reassembler: Reassembler; + private readonly sendReceipt: SendReceipt; private readonly session: Session; private readonly smsIdFormat: SmsIdFormat; private readonly systemId: string; - private refusing = false; constructor(options: IncomingRequestsOptions) { this.dlrMerger = options.dlrMerger; - this.held = new HeldMessages({ - link: options.link, + this.handlers = new RunningHandlers({ log: options.log, - max: defaults.maxHeldMessages, - maxOctets: defaults.maxHeldOctets, - sendPastDrain: options.sendPastDrain, - session: options.session, - timeout: defaults.heldMessageTimeout, + max: bounds.maxRunningHandlers, + onFailure: err => { options.session.emit('sessionError', err); }, + timeout: bounds.handlerTimeout, }); - this.link = options.link; this.log = options.log; this.onRequest = options.onRequest; + this.onSms = options.onSms; this.reassembler = new Reassembler({ log: options.log, max: options.maxReassembly ?? defaults.maxReassembly, @@ -91,20 +89,26 @@ 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; } + /** How many `onSms` handlers are still running. */ + get running(): number { + return this.handlers.size; + } + async handle(pduObj: PduObject): Promise { - const generation = this.link.generation(); + const arrivedOn = this.session.sock; const { onRequest } = this; // Called unbound, so the application's hook never sees this class as its `this`. if (onRequest && await onRequest(this.session, pduObj)) return; - // The link it arrived on went while the hook ran, so nothing we answer now correlates. - if (this.link.generation() !== generation) { + // An answer carries the request's sequence number, which only the socket it arrived on knows. + if (arrivedOn.destroyed || this.session.sock !== arrivedOn) { this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName }); return; @@ -148,26 +152,19 @@ export class IncomingRequests { } } - /** Drops the segments of every message that never became whole, and of every one still held. */ - clear(): void { - this.refusing = false; - this.held.clear(); + /** The link went: its half-arrived groups go with it, since their references were that link's. */ + linkLost(): void { this.reassembler.clear(); } - listenerRejected(sms: unknown): void { - this.held.listenerRejected(sms); + /** Waits out the handlers still running, and says how many never finished. */ + idle(timeout: number, signal: AbortSignal | undefined): Promise { + return this.handlers.idle(timeout, signal); } - /** Waits out the messages the application still holds, and says how many it never answered. */ - async drain(timeout: number, signal: AbortSignal | undefined): Promise { - const unanswered = await this.held.idle(timeout, signal); - - if (unanswered === 0) return {}; - - this.log.warn('session - shutting down with messages unanswered', { timeout, unanswered }); - - return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) }; + /** The session is over, so no drain waits on a handler any more. */ + release(): void { + this.handlers.release(); } private async unhandled(pduObj: PduObject): Promise { @@ -211,49 +208,44 @@ export class IncomingRequests { await this.session.sendReturn(pduObj); } - private async refusedAtBound(pduObj: PduObject): Promise { - if (this.held.full()) { - if (!this.refusing) { - this.refusing = true; - this.log.warn('session - unanswered messages at their bound, refusing new ones until the application answers', { - messages: this.held.size, - octets: this.held.octetsHeld, - }); - } - - this.log.verbose('session - unanswered messages at their bound, asking the peer to retry', { - cmdName: pduObj.cmdName, - seqNr: pduObj.seqNr, - }); - await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj))); + /** Why a message cannot be taken right now, or undefined where it can. */ + private refusal(pduObj: PduObject): ErrorName | undefined { + if (!this.onSms) { + this.log.info('session - refusing a message, since no onSms handler takes them', { cmdName: pduObj.cmdName }); - return true; + return 'ESME_RX_P_APPN'; } - // 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 }); - } + if (!this.handlers.full()) return undefined; + + this.log.verbose('session - handlers at their bound, asking the peer to retry', { + cmdName: pduObj.cmdName, + seqNr: pduObj.seqNr, + }); - return false; + return throttledStatus(this.carriedAs(pduObj)); } /** - * 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. + * Every message is answered as its segments arrive, before the handler sees it: a peer that + * dispatches one request at a time never sends the second segment until the first is answered. */ private async onMessage(pduObj: PduObject): Promise { - if (await this.refusedAtBound(pduObj)) return; + const refused = this.refusal(pduObj); + + if (refused) { + await this.session.sendReturn(pduObj, refused); + + return; + } const concat = concatOf(pduObj); if (!concat) { - this.held.offer([detach(pduObj)]); + const smsId = uuidv7(); + + await this.session.sendReturn(pduObj, 'ESME_ROK', respIdParams(pduObj.cmdName, smsId)); + this.deliver([detach(pduObj)], smsId); return; } @@ -275,7 +267,13 @@ export class IncomingRequests { respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)), ); - if (collected.whole) this.held.offer(collected.whole, collected.smsId); + if (collected.whole) this.deliver(collected.whole, collected.smsId); + } + + private deliver(pduObjs: PduObject[], smsId: string): void { + if (!this.onSms) return; + + this.handlers.run(createSms({ pduObjs, session: this.session, smsId }, this.sendReceipt), this.onSms); } private reportLost(lost: LostGroup): void { diff --git a/src/link-life.ts b/src/session/link-life.ts similarity index 84% rename from src/link-life.ts rename to src/session/link-life.ts index f44f2ea..7b2e7c2 100644 --- a/src/link-life.ts +++ b/src/session/link-life.ts @@ -1,5 +1,5 @@ -import type { SmppLog } from './log.ts'; -import type { VoidResult } from './result.ts'; +import type { SmppLog } from '../log.ts'; +import type { VoidResult } from '../result.ts'; export type LinkLifeOptions = { log: SmppLog; @@ -10,7 +10,10 @@ export type LinkLifeOptions = { timeout: number; }; -/** `binding`: a socket is attached and its bind is not answered yet, so it carries nothing but that bind. */ +/** + * `binding`: a socket is attached and no bind is recorded on it yet, so it carries link commands + * alone. `up`: bound, and carrying everything. `down`: no socket, one to come unless stopped. + */ type Phase = 'binding' | 'down' | 'ended' | 'up'; type Waiter = (result: VoidResult) => void; @@ -27,15 +30,14 @@ function over(): Error { return new Error('Session is closed'); } -/** Whether the session's link lives, and where a request with no link to go out on waits for the next one. */ +/** Whether the session's link carries requests, and where one with no link to go out on waits for the next. */ export class LinkLife { private readonly log: SmppLog; private readonly now: () => number; private readonly reconnects: boolean; private readonly timeout: number; private readonly waiting = new Set(); - private drops = 0; - private phase: Phase = 'up'; + private phase: Phase = 'binding'; private stopped = false; constructor(options: LinkLifeOptions) { @@ -74,18 +76,13 @@ export class LinkLife { return !this.isUp() && !this.isOver() && this.retrying(); } - /** Changes with every drop, so what was read off one link can tell that link is gone. */ - generation(): number { - return this.drops; - } - /** Why no request will ever be admitted, or undefined while one may still get through. */ refusal(): Error | undefined { return this.isUp() || this.awaitsNextLink() ? undefined : over(); } /** One budget for a request, however many links it waits through. */ - hold(signal: AbortSignal | undefined): () => Promise { + budget(signal: AbortSignal | undefined): () => Promise { const deadline = this.timeout > 0 ? this.now() + this.timeout : 0; return () => this.wait(deadline, signal); @@ -100,6 +97,8 @@ export class LinkLife { /** The link is bound: everything held goes out on it. */ open(): void { + if (!this.isAttached()) return; + this.phase = 'up'; if (this.waiting.size > 0) { @@ -109,14 +108,13 @@ export class LinkLife { this.release({}); } - /** The attached link is gone: the event that says so, or undefined when there was none to lose. */ - drop(): 'close' | 'disconnected' | undefined { - if (!this.isAttached()) return undefined; + /** The attached link is gone. False when there was none to lose. */ + drop(): boolean { + if (!this.isAttached()) return false; this.phase = 'down'; - this.drops++; - return this.retrying() ? 'disconnected' : 'close'; + return true; } stop(): void { diff --git a/src/link-timers.ts b/src/session/link-timers.ts similarity index 97% rename from src/link-timers.ts rename to src/session/link-timers.ts index cd6710a..cbe1d36 100644 --- a/src/link-timers.ts +++ b/src/session/link-timers.ts @@ -1,4 +1,4 @@ -import type { SmppLog } from './log.ts'; +import type { SmppLog } from '../log.ts'; export type LinkTimersOptions = { /** How long between enquire_link probes. Undefined or 0 never probes. */ diff --git a/src/session/outgoing-requests.ts b/src/session/outgoing-requests.ts new file mode 100644 index 0000000..8c83587 --- /dev/null +++ b/src/session/outgoing-requests.ts @@ -0,0 +1,151 @@ +import type { LinkLife } from './link-life.ts'; +import type { PduObject, PduObjectInput } from '../wire/pdu.ts'; +import type { PduTransport } from './pdu-transport.ts'; +import type { Result } from '../result.ts'; +import type { SendOptions } from './session-options.ts'; +import type { SmppLog } from '../log.ts'; +import { PendingRequests } from './pending-requests.ts'; +import { SendWindow } from './send-window.ts'; +import { UnansweredError } from '../messages/unanswered-error.ts'; +import { objToPdu } from '../wire/pdu.ts'; + +export type OutgoingRequestsOptions = { + link: LinkLife; + log: SmppLog; + maxOutstanding: number; + responseTimeout: number; + transport: PduTransport; +}; + +/** + * Which checks a request takes on its way out. `message`: refused once a shutdown began, waits for + * a bound link and a window slot, and is retried on the next link if it never reached the socket. + * `receipt`: the same, but let past a shutdown, since it reports on a message this session took. + * `link`: a bind, an unbind or a keepalive, which goes out now on the socket as it is. + */ +export type Lane = 'link' | 'message' | 'receipt'; + +/** `retry`: the write failed, so nothing reached the socket and another link may carry it. */ +type Attempt = { result: Result<{ pduObj: PduObject }>; retry: boolean }; + +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; + private readonly pending: PendingRequests; + private readonly responseTimeout: number; + private readonly transport: PduTransport; + private readonly window: SendWindow; + + constructor(options: OutgoingRequestsOptions) { + this.link = options.link; + this.pending = new PendingRequests(options.log); + this.responseTimeout = options.responseTimeout; + this.transport = options.transport; + this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); + } + + canCarry(): boolean { + return this.link.isUp() && !this.transport.sock.destroyed; + } + + /** The link is gone, and every answer still owed on it with it. */ + linkLost(): void { + this.pending.settleAll(new Error('Session closed before a response arrived')); + } + + /** Hands a response to the request waiting for it. False means nothing was. */ + deliver(pduObj: PduObject): boolean { + return this.pending.deliver(pduObj); + } + + /** A response the codec refused settles its request instead of leaving it to time out. */ + settleRefused(seqNr: number, err: Error): void { + this.pending.settle(seqNr, { err }); + } + + /** Resolves 0 once nothing is left on the wire or queued for it, or with how much still is. */ + idle(timeout: number, signal: AbortSignal | undefined): Promise { + return this.window.idle(timeout, signal); + } + + /** A request on a bound link with a free slot reaches the socket before this returns. */ + request(input: PduObjectInput, options: SendOptions, lane: Lane): Promise> { + // Before the drain, the link and the window: a call that can never go out waits for none of them. + const refused = misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); + + if (refused) return Promise.resolve({ err: refused }); + + if (lane === 'link') return this.attempt(input, options).then(attempt => attempt.result); + + if (lane === 'message') { + const shut = this.link.refusal() ?? (this.link.isStopped() ? new Error('Session is shutting down') : undefined); + + if (shut) return Promise.resolve({ err: shut }); + } + + return this.carry(input, options); + } + + private async carry(input: PduObjectInput, options: SendOptions): Promise> { + const budget = this.link.budget(options.signal); + + for (;;) { + if (!this.link.isUp()) { + const held = await budget(); + + if (held.err) return { err: held.err }; + } + + if (!this.window.take()) { + const slot = await this.window.wait(options.signal); + + if (slot.err) return { err: slot.err }; + } + + const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); + + // Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins. + if (!attempt.retry || !this.link.awaitsNextLink()) return attempt.result; + } + } + + private async attempt(input: PduObjectInput, options: SendOptions): Promise { + // pending.wait() alone settles the caller while the request still goes out to the peer. + if (options.signal?.aborted === true) { + return { result: { err: abortedBeforeSend() }, retry: false }; + } + + const seqNr = this.pending.nextSeqNr(); + const built = objToPdu({ ...input, seqNr }); + + if (built.err) return { result: { err: built.err }, retry: false }; + + const response = this.pending.wait(seqNr, { + signal: options.signal, + timeout: this.responseTimeout, + }); + const written = this.transport.write(built.buffer); + + if (written.err) { + this.pending.settle(seqNr, { err: written.err }); + + return { result: { err: written.err }, retry: true }; + } + + const answered = await response; + + // It went out, so a failure now means the peer may have taken it and the answer was the loss. + return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retry: false }; + } +} diff --git a/src/pdu-transport.ts b/src/session/pdu-transport.ts similarity index 90% rename from src/pdu-transport.ts rename to src/session/pdu-transport.ts index 966cdee..b559990 100644 --- a/src/pdu-transport.ts +++ b/src/session/pdu-transport.ts @@ -1,10 +1,10 @@ -import type { PduObject } from './pdu.ts'; -import type { SmppLog } from './log.ts'; +import type { PduObject } from '../wire/pdu.ts'; +import type { SmppLog } from '../log.ts'; import type { Socket } from 'node:net'; -import type { VoidResult } from './result.ts'; -import { PduFramer } from './pdu-framer.ts'; -import { PduRefusedError } from './pdu-refusal.ts'; -import { pduToObj } from './pdu.ts'; +import type { VoidResult } from '../result.ts'; +import { PduFramer } from '../wire/pdu-framer.ts'; +import { PduRefusedError } from '../wire/pdu-refusal.ts'; +import { pduToObj } from '../wire/pdu.ts'; export type PduTransportOptions = { log: SmppLog; diff --git a/src/pending-requests.ts b/src/session/pending-requests.ts similarity index 92% rename from src/pending-requests.ts rename to src/session/pending-requests.ts index 7949106..b6bdff9 100644 --- a/src/pending-requests.ts +++ b/src/session/pending-requests.ts @@ -1,7 +1,7 @@ -import type { PduObject } from './pdu.ts'; -import type { Result } from './result.ts'; -import type { SmppLog } from './log.ts'; -import { maxSeqNr } from './pdu.ts'; +import type { PduObject } from '../wire/pdu.ts'; +import type { Result } from '../result.ts'; +import type { SmppLog } from '../log.ts'; +import { maxSeqNr } from '../wire/pdu.ts'; export type WaitOptions = { signal?: AbortSignal | undefined; diff --git a/src/reconnect-loop.ts b/src/session/reconnect-loop.ts similarity index 88% rename from src/reconnect-loop.ts rename to src/session/reconnect-loop.ts index af8044d..a59431b 100644 --- a/src/reconnect-loop.ts +++ b/src/session/reconnect-loop.ts @@ -1,11 +1,8 @@ -import type { Result, VoidResult } from './result.ts'; -import type { SmppLog } from './log.ts'; +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'; +import { errorFrom } from '../error-from.ts'; export type ReconnectLoopOptions = { connect: () => Promise>; @@ -32,8 +29,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.maxDelay; + this.minDelay = options.minDelay ?? defaults.minDelay; this.now = options.now ?? Date.now; this.options = options; this.delay = this.minDelay; @@ -81,7 +78,7 @@ export class ReconnectLoop { // connect() and onConnected() are the application's, so a throw from either lands here. const retry = await this.attempt().catch((thrown: unknown) => { - const err = thrown instanceof Error ? thrown : new Error(String(thrown)); + const err = errorFrom(thrown); this.options.log.error('reconnect - an attempt threw', { message: err.message }); @@ -137,7 +134,7 @@ export class ReconnectLoop { } catch (thrown: unknown) { sock.destroy(); - return { err: thrown instanceof Error ? thrown : new Error(String(thrown)) }; + return { err: errorFrom(thrown) }; } } } diff --git a/src/session/running-handlers.ts b/src/session/running-handlers.ts new file mode 100644 index 0000000..5e15723 --- /dev/null +++ b/src/session/running-handlers.ts @@ -0,0 +1,97 @@ +import type { SmppLog } from '../log.ts'; +import type { SmsHandler } from './session-options.ts'; +import type { Sms } from '../messages/sms.ts'; +import { IdleWaiters } from './idle-waiters.ts'; +import { errorFrom } from '../error-from.ts'; + +export type RunningHandlersOptions = { + log: SmppLog; + max: number; + /** What a handler that threw or rejected is reported through. */ + onFailure: (err: Error) => void; + /** A handler running this long is no longer counted or waited for. */ + timeout: number; +}; + +/** The `onSms` handlers in flight: the bound a peer is throttled at, and what a drain waits for. */ +export class RunningHandlers { + private readonly idleWaiters = new IdleWaiters(); + private readonly options: RunningHandlersOptions; + private atBound = false; + private running = 0; + + constructor(options: RunningHandlersOptions) { + this.options = options; + } + + get size(): number { + return this.running; + } + + /** Whether a message arriving now has to be refused. Logs once each way the bound is crossed. */ + full(): boolean { + const { log, max } = this.options; + + if (this.running >= max) { + if (!this.atBound) { + this.atBound = true; + log.warn('session - handlers at their bound, refusing messages until some finish', { running: this.running }); + } + + return true; + } + + // Half, so a peer keeping its window full does not flip this on every finished handler. + if (this.atBound && this.running <= max / 2) { + this.atBound = false; + log.info('session - handlers down to half their bound, accepting messages again', { running: this.running }); + } + + return false; + } + + /** Runs the handler and counts it until it settles or its deadline passes. */ + run(sms: Sms, handler: SmsHandler): void { + const { log, timeout } = this.options; + let counted = true; + const finished = (): void => { + if (!counted) return; + + counted = false; + this.running--; + + if (this.running === 0) this.idleWaiters.settle(); + }; + const expired = setTimeout(() => { + log.warn('session - a handler still running past its deadline is no longer waited for', { timeout }); + finished(); + }, timeout); + + expired.unref(); + this.running++; + + // A handler that throws is the application's bug; it must not become ours. Hard rule 1. + Promise.resolve().then(() => handler(sms)).then( + () => undefined, + (thrown: unknown) => { + const err = errorFrom(thrown); + + log.error('session - an onSms handler failed', { message: err.message }); + this.options.onFailure(err); + }, + ).finally(() => { + clearTimeout(expired); + finished(); + }).catch(() => undefined); + } + + /** Resolves 0 once every handler has finished, or with how many have not. */ + idle(timeout: number, signal: AbortSignal | undefined): Promise { + return this.idleWaiters.wait(() => this.running, timeout, signal); + } + + /** The session is over: whatever still runs is the application's, and no drain waits on it. */ + release(): void { + this.idleWaiters.settle(); + } +} diff --git a/src/send-window.ts b/src/session/send-window.ts similarity index 77% rename from src/send-window.ts rename to src/session/send-window.ts index e13d67d..e0f8984 100644 --- a/src/send-window.ts +++ b/src/session/send-window.ts @@ -1,5 +1,5 @@ -import type { SmppLog } from './log.ts'; -import type { VoidResult } from './result.ts'; +import type { SmppLog } from '../log.ts'; +import type { VoidResult } from '../result.ts'; import { IdleWaiters } from './idle-waiters.ts'; export type SendWindowOptions = { @@ -26,17 +26,39 @@ export class SendWindow { this.log = options.log; } - /** Resolves once a slot is the caller's, or with the reason it stopped waiting for one. */ - acquire(signal: AbortSignal | undefined): Promise { - if (this.inFlight < this.limit) { - this.inFlight++; + /** Takes a slot on the spot. False means the window is full and `wait()` is the way in. */ + take(): boolean { + if (this.inFlight >= this.limit) return false; - return Promise.resolve({}); - } + this.inFlight++; + + return true; + } + /** Resolves once a freed slot is the caller's, or with the reason it stopped waiting for one. */ + wait(signal: AbortSignal | undefined): Promise { if (signal?.aborted === true) return Promise.resolve({ err: aborted() }); - return this.queue(signal); + this.log.verbose('sendWindow - queueing a request behind a full window', { + limit: this.limit, + queued: this.waiting.size + 1, + }); + + // A waiter leaves the queue as it settles, so release() can only hand a slot to one still in it. + return new Promise(resolve => { + const settle = (result: VoidResult): void => { + this.waiting.delete(settle); + signal?.removeEventListener('abort', onAbort); + resolve(result); + }; + + function onAbort(): void { + settle({ err: aborted() }); + } + + signal?.addEventListener('abort', onAbort, { once: true }); + this.waiting.add(settle); + }); } release(): void { @@ -65,27 +87,4 @@ export class SendWindow { idle(timeout: number, signal: AbortSignal | undefined): Promise { return this.idleWaiters.wait(() => this.unfinished(), timeout, signal); } - - /** A waiter leaves the queue as it settles, so release() can only hand a slot to one still in it. */ - private queue(signal: AbortSignal | undefined): Promise { - this.log.verbose('sendWindow - queueing a request behind a full window', { - limit: this.limit, - queued: this.waiting.size + 1, - }); - - return new Promise(resolve => { - const settle = (result: VoidResult): void => { - this.waiting.delete(settle); - signal?.removeEventListener('abort', onAbort); - resolve(result); - }; - - function onAbort(): void { - settle({ err: aborted() }); - } - - signal?.addEventListener('abort', onAbort, { once: true }); - this.waiting.add(settle); - }); - } } diff --git a/src/session-options.ts b/src/session/session-options.ts similarity index 62% rename from src/session-options.ts rename to src/session/session-options.ts index 0b768c9..b9f36ff 100644 --- a/src/session-options.ts +++ b/src/session/session-options.ts @@ -1,17 +1,16 @@ -import type { Dlr } from './dlr.ts'; -import type { MessageDlr } from './dlr-merger.ts'; -import type { PduObject } from './pdu.ts'; -import type { PduRefusedError } from './pdu-refusal.ts'; -import type { Result, VoidResult } from './result.ts'; -import type { Session } from './session.ts'; -import type { SmppLog } from './log.ts'; -import type { SmsIdFormat } from './sms-id.ts'; -import type { Sms } from './sms.ts'; +import type { Dlr } from '../messages/dlr.ts'; +import type { MessageDlr } from '../messages/dlr-merger.ts'; +import type { PduObject } from '../wire/pdu.ts'; +import type { PduRefusedError } from '../wire/pdu-refusal.ts'; +import type { Result, VoidResult } from '../result.ts'; +import type { Session } from '../session.ts'; +import type { SmppLog } from '../log.ts'; +import type { SmsIdFormat } from '../messages/sms-id.ts'; +import type { Sms } from '../messages/sms.ts'; import type { Socket } from 'node:net'; -import { backoffDefaults } from './reconnect-loop.ts'; -import { defaultMaxOctets } from './reassembly.ts'; -import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts'; -import { namedValue } from './error-from.ts'; +import { defaults } from '../defaults.ts'; +import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from '../messages/sms-id.ts'; +import { namedValue } from '../error-from.ts'; export type SessionEvents = { close: []; @@ -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,12 @@ export type CloseOptions = { signal?: AbortSignal | undefined }; */ export type OnRequest = (session: Session, pduObj: PduObject) => Promise | boolean; +/** + * Every message the peer sends, already answered. A shutdown waits for the promise it returns; + * one that throws or rejects reaches `sessionError`. + */ +export type SmsHandler = (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 +61,12 @@ export type SessionOptions = { maxOutstanding?: number | undefined; maxReassembly?: number | undefined; onRequest?: OnRequest | undefined; + /** Without one, every message the peer sends is refused as unwanted. */ + onSms?: SmsHandler | 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 drain 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,56 +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. - */ export function checkSessionOptions(options: CheckableOptions): VoidResult { if (options.fromStart !== undefined) { return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') }; @@ -175,6 +88,10 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { if (connect.err) return connect; + const hooks = checkHooks(options); + + if (hooks.err) return hooks; + const checked = checkLimits(limitsOf(options)); if (checked.err) return checked; @@ -184,10 +101,27 @@ 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 {}; +} + +/** + * 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. + */ function limitsOf(options: CheckableOptions): [string, number, number][] { return [ ['idleTimeout', options.idleTimeout ?? 0, 0], - ['maxOctets', options.maxOctets ?? defaultMaxOctets, 1], + ['maxOctets', options.maxOctets ?? defaults.maxOctets, 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.maxDelay); + const minDelay = delayOr(reconnect.minDelay, defaults.minDelay); // 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/sms.ts b/src/sms.ts deleted file mode 100644 index 5149ceb..0000000 --- a/src/sms.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { ErrorName } from './defs/errors.ts'; -import type { MessageState } from './defs/constants.ts'; -import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; -import type { Result, VoidResult } from './result.ts'; -import type { Session } from './session.ts'; -import { UnansweredError } from './unanswered-error.ts'; -import { consts } from './defs/constants.ts'; -import { decodeSegments } from './reassembly.ts'; -import { messageClassOf } from './defs/encodings.ts'; -import { paramText } from './defs/types.ts'; -import { receiptCodes, transientStates } from './dlr.ts'; -import { smppDate } from './message.ts'; -import { respIdParams, segmentId } from './sms-id.ts'; -import { uuidv7 } from './uuid.ts'; - -/** `pduObjs` holds what the peer took, so a partial failure names what is already receipted. */ -export type SendDlrResult = { - err?: Error; - pduObjs: PduObject[]; - /** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */ - unanswered: number; -}; - -export type SendRespOptions = { - /** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */ - smsId?: string; - status?: ErrorName; -}; - -/** - * A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying - * every segment's PDU. - */ -export type Sms = { - /** - * Whether the peer was answered as the message's segments arrived, which is what a concatenated - * message needs and a segment count cannot tell you. `sendResp()` then writes nothing. - */ - answeredOnArrival: boolean; - dlr: boolean; - /** GSM 03.38 message class 0: shown on arrival and not stored. */ - flash: boolean; - from: string; - message: string; - pduObjs: PduObject[]; - /** Sends a delivery report back to the sender. Defaults to DELIVERED. */ - sendDlr: (status?: MessageState) => Promise; - /** - * 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. - */ - sendResp: (options?: SendRespOptions) => Promise; - session: Session; - /** The id the segments were answered with, the id `sendResp()` was given, or a generated UUID v7. */ - readonly smsId: string; - submitTime: Date; - to: string; -}; - -export type SmsInput = { - /** The id base the segments were already answered with; absent leaves the answer to `sendResp()`. */ - answeredAs?: string | undefined; - pduObjs: PduObject[]; - session: Session; -}; - -export type SmsHandlers = { - answered: () => void; - lostLink: () => boolean; - send: (input: PduObjectInput) => Promise>; -}; - -/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ -const immediateDisplayClass = 0; - -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 sms: Sms = { - answeredOnArrival: input.answeredAs !== undefined, - dlr: typeof registered === 'number' && registered !== 0, - flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass, - from: paramText(first?.params.source_addr), - 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)), - session: input.session, - get smsId(): string { - return answered.smsId; - }, - submitTime: new Date(), - to: paramText(first?.params.destination_addr), - }; - - 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, -): Promise { - if (options.smsId !== undefined) { - return Promise.resolve({ - 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'), - }); - } - - handlers.answered(); - - return Promise.resolve({}); -} - -async function sendResp( - sms: Sms, - session: Session, - answered: { smsId: string }, - options: SendRespOptions, - handlers: Pick, -): Promise { - 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; - - // 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( - pduObj, - options.status ?? 'ESME_ROK', - respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)), - ))); - - const failure = results.find(result => result.err); - - if (!failure) handlers.answered(); - - return failure ?? {}; -} - -/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ -function receiptText(sms: Sms, smsId: string, status: MessageState): string { - const delivered = status === 'DELIVERED'; - const failed = !delivered && !transientStates.includes(status); - - return [ - `id:${smsId}`, - 'sub:001', - `dlvrd:${delivered ? '001' : '000'}`, - `submit date:${smppDate(sms.submitTime)}`, - `done date:${smppDate(new Date())}`, - `stat:${receiptCodes[status]}`, - `err:${failed ? '001' : '000'}`, - 'text:', - ].join(' '); -} - -function receiptTlvs(smsId: string, status: MessageState): TlvInputs { - return { - message_state: { tagValue: consts.MESSAGE_STATE[status] }, - receipted_message_id: { tagValue: smsId }, - }; -} - -function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult { - const pduObjs: PduObject[] = []; - let failure: Error | undefined; - let unanswered = 0; - - for (const one of sent) { - if (one.err) { - if (one.err instanceof UnansweredError) unanswered++; - - failure ??= one.err; - } else if (one.pduObj.cmdStatus === 'ESME_ROK') { - pduObjs.push(one.pduObj); - } else { - const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId); - - failure ??= new Error(`deliver_sm refused by the peer: ${refusal}`); - } - } - - return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered }; -} - -async function sendDlr( - sms: Sms, - session: Session, - handlers: Pick, - status: MessageState = 'DELIVERED', -): Promise { - if (!session.bindAllows('deliver_sm')) { - return { - err: new Error('A transmitter-bound session does not carry deliver_sm'), - pduObjs: [], - unanswered: 0, - }; - } - - const total = sms.pduObjs.length; - // Together, not one after a response: a drain waiting for this message must see the whole receipt. - const sent = await Promise.all(sms.pduObjs.map((_segment, index) => { - const smsId = segmentId(sms.smsId, index, total); - - return handlers.send({ - cmdName: 'deliver_sm', - params: { - destination_addr: sms.from, - esm_class: transientStates.includes(status) - ? consts.ESM_CLASS.INTERMEDIATE_DELIVERY - : consts.ESM_CLASS.MC_DELIVERY_RECEIPT, - short_message: receiptText(sms, smsId, status), - source_addr: sms.to, - }, - ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), - }); - })); - return collectReceipt(sent); -} diff --git a/src/pdu-framer.ts b/src/wire/pdu-framer.ts similarity index 97% rename from src/pdu-framer.ts rename to src/wire/pdu-framer.ts index 1b23dea..4aa021a 100644 --- a/src/pdu-framer.ts +++ b/src/wire/pdu-framer.ts @@ -1,4 +1,4 @@ -import type { Result } from './result.ts'; +import type { Result } from '../result.ts'; import { framingRefusal } from './pdu-refusal.ts'; /** diff --git a/src/pdu-refusal.ts b/src/wire/pdu-refusal.ts similarity index 92% rename from src/pdu-refusal.ts rename to src/wire/pdu-refusal.ts index 45b7a2a..44362c5 100644 --- a/src/pdu-refusal.ts +++ b/src/wire/pdu-refusal.ts @@ -1,6 +1,6 @@ -import type { CommandName } from './defs/commands.ts'; -import type { ErrorName } from './defs/errors.ts'; -import { respNameFor } from './defs/commands.ts'; +import type { CommandName } from '../defs/commands.ts'; +import type { ErrorName } from '../defs/errors.ts'; +import { respNameFor } from '../defs/commands.ts'; /** A hostile peer must not be able to make us allocate arbitrarily. */ export const maxPduLength = 1024 * 1024; diff --git a/src/pdu.ts b/src/wire/pdu.ts similarity index 95% rename from src/pdu.ts rename to src/wire/pdu.ts index 743cbed..0ad7171 100644 --- a/src/pdu.ts +++ b/src/wire/pdu.ts @@ -1,16 +1,16 @@ -import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; -import type { ErrorName } from './defs/errors.ts'; -import type { ParamValue } from './defs/types.ts'; +import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from '../defs/commands.ts'; +import type { ErrorName } from '../defs/errors.ts'; +import type { ParamValue } from '../defs/types.ts'; import type { PduHeader } from './pdu-refusal.ts'; -import type { Result, VoidResult } from './result.ts'; -import type { TlvInputs, Tlvs } from './defs/tlvs.ts'; +import type { Result, VoidResult } from '../result.ts'; +import type { TlvInputs, Tlvs } from '../defs/tlvs.ts'; import { PduRefusedError, framingRefusal } from './pdu-refusal.ts'; -import { cmds, commandNameById, respNameFor } from './defs/commands.ts'; -import { hasUdh } from './defs/constants.ts'; -import { decodeMessage, encodeBody } from './message.ts'; -import { errorNameById, errors, isErrorName } from './defs/errors.ts'; -import { paramNumber, valueText } from './defs/types.ts'; -import { parseTlvs, writeTlvs } from './defs/tlvs.ts'; +import { cmds, commandNameById, respNameFor } from '../defs/commands.ts'; +import { hasUdh } from '../defs/constants.ts'; +import { decodeMessage, encodeBody } from '../messages/message.ts'; +import { errorNameById, errors, isErrorName } from '../defs/errors.ts'; +import { paramNumber, valueText } from '../defs/types.ts'; +import { parseTlvs, writeTlvs } from '../defs/tlvs.ts'; /** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */ export const maxSeqNr = 2147483646; diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts index 8132883..44200d6 100644 --- a/test/declared-alphabet.test.ts +++ b/test/declared-alphabet.test.ts @@ -4,13 +4,13 @@ import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; import { client } from '../src/client.ts'; import { closeAfter } from './teardown.ts'; import { consts } from '../src/defs/constants.ts'; -import { decodeMessage } from '../src/message.ts'; -import { dlrFromPdu } from '../src/dlr.ts'; +import { decodeMessage } from '../src/messages/message.ts'; +import { dlrFromPdu } from '../src/messages/dlr.ts'; import { encodingByDataCoding, encodings } from '../src/defs/encodings.ts'; -import { objToPdu, pduToObj } from '../src/pdu.ts'; +import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; import { paramNumber } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; -import type { PduObject } from '../src/pdu.ts'; +import type { PduObject } from '../src/wire/pdu.ts'; const from = '46701113311'; const to = '46709771337'; @@ -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 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.GSM7.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,15 @@ 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: sms => 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 +164,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), 'GSM7'); const octets = Buffer.from('436f73742035022000686f6d65', 'hex'); diff --git a/test/dlr.test.ts b/test/dlr.test.ts index fa6ec84..a1cc2b8 100644 --- a/test/dlr.test.ts +++ b/test/dlr.test.ts @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; import { consts } from '../src/defs/constants.ts'; -import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts'; -import { encodeMessage } from '../src/message.ts'; -import { objToPdu, pduToObj } from '../src/pdu.ts'; -import type { PduObject, TlvInputs } from '../src/pdu.ts'; +import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/messages/dlr.ts'; +import { encodeMessage } from '../src/messages/message.ts'; +import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; +import type { PduObject, TlvInputs } from '../src/wire/pdu.ts'; const receiptText = 'id:0195f0c7 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:hello there'; diff --git a/test/dummy-smsc.ts b/test/dummy-smsc.ts index 7da2609..5649ba2 100644 --- a/test/dummy-smsc.ts +++ b/test/dummy-smsc.ts @@ -2,12 +2,12 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import type { Session } from '../src/session.ts'; import type { TestContext } from 'node:test'; -import { PduFramer } from '../src/pdu-framer.ts'; +import { PduFramer } from '../src/wire/pdu-framer.ts'; import { client } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; import { consts } from '../src/defs/constants.ts'; -import { objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; -import { uuidv7 } from '../src/uuid.ts'; +import { objToPdu, pduReturn, pduToObj } from '../src/wire/pdu.ts'; +import { uuidv7 } from '../src/messages/uuid.ts'; export type DummySmsc = { /** Writes a delivery receipt to the ESME, its body spelled as the test names it. */ diff --git a/test/encodings.test.ts b/test/encodings.test.ts index 67fc68c..c064c39 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('GSM7 (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.GSM7.match('')); + assert.ok(encodings.GSM7.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); + assert.ok(encodings.GSM7.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); + assert.ok(encodings.GSM7.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); + assert.ok(encodings.GSM7.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.GSM7.match('`')); + assert.ok(!encodings.GSM7.match('ÁáçÚUÓO')); + assert.ok(!encodings.GSM7.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.GSM7.encode(str), Buffer.from(bytes)); + assert.equal(encodings.GSM7.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(''), 'GSM7'); + assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'GSM7'); 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', 'GSM7'), { char: 'ï', index: 6 }); + assert.equal(unencodable('€{}[]\\~^|\f', 'GSM7'), undefined); + assert.equal(unencodable('Åsa', 'GSM7'), 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', 'GSM7'), { char: '😀', index: 1 }); }); test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => { @@ -147,15 +147,15 @@ 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', 'GSM7'), undefined); + assert.deepEqual(encodings.GSM7.encode('\x1Be'), encodings.GSM7.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), 'GSM7'); + assert.equal(encodingByDataCoding(0x01), 'GSM7'); assert.equal(encodingByDataCoding(0x08), 'UCS2'); }); @@ -166,17 +166,17 @@ describe('encodingByDataCoding()', () => { }); 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), 'GSM7'); + assert.equal(encodingByDataCoding(0x11), 'GSM7'); assert.equal(encodingByDataCoding(0x18), 'UCS2'); assert.equal(encodingByDataCoding(0x1A), 'UCS2'); - assert.equal(encodingByDataCoding(0xF0), 'ASCII'); - assert.equal(encodingByDataCoding(0xF1), 'ASCII'); + assert.equal(encodingByDataCoding(0xF0), 'GSM7'); + assert.equal(encodingByDataCoding(0xF1), 'GSM7'); }); // 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), 'GSM7'); assert.equal(encodingByDataCoding(0x38), 'UCS2'); assert.equal(encodingByDataCoding(0x54), 'LATIN1'); assert.equal(encodingByDataCoding(0x58), 'UCS2'); @@ -197,9 +197,9 @@ describe('encodingByDataCoding()', () => { }); test('falls back to ASCII for alphabets it has no codec for', () => { - assert.equal(encodingByDataCoding(0x05), 'ASCII'); - assert.equal(encodingByDataCoding(0x0E), 'ASCII'); + assert.equal(encodingByDataCoding(0x05), 'GSM7'); + assert.equal(encodingByDataCoding(0x0E), 'GSM7'); // 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), 'GSM7'); }); }); diff --git a/test/interop.test.ts b/test/interop.test.ts index 2e5d905..df93fee 100644 --- a/test/interop.test.ts +++ b/test/interop.test.ts @@ -2,19 +2,29 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; import reference from 'smpp'; import type { ReferenceSession } from 'smpp'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import { client } from '../src/client.ts'; import { closeAfter } from './teardown.ts'; -import { concatInfo } from '../src/udh.ts'; -import { objToPdu, pduToObj } from '../src/pdu.ts'; +import { concatInfo } from '../src/messages/udh.ts'; +import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; import { server } from '../src/server.ts'; -import { splitMessage } from '../src/message.ts'; +import { splitMessage } from '../src/messages/message.ts'; /** * Cross-checks against farhadi/node-smpp, an independent SMPP implementation. This is what backs * the claim that the corrected framing is right rather than differently wrong. */ +type Deferred = { promise: Promise; resolve: (value: T) => void }; + +/** A promise settled from the outside, for a handler that has to exist before what fires it. */ +function deferred(): Deferred { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise(settle => { resolve = settle; }); + + return { promise, resolve }; +} + function ourBuffer(...args: Parameters): Buffer { const { buffer, err } = objToPdu(...args); @@ -249,16 +259,13 @@ 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 }); + const incoming = deferred(); + const { err: serverErr, server: smpp } = await server({ onSms: incoming.resolve, port: 0 }); assert.equal(serverErr, undefined); assert.ok(smpp); closeAfter(t, smpp); - const incoming = new Promise(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); - const refSession = reference.connect({ url: `smpp://localhost:${String(smpp.port)}`, }); @@ -275,10 +282,9 @@ describe('a live session against the reference implementation', () => { source_addr: '46701113311', }); - const sms = await incoming; + const sms = await incoming.promise; assert.equal(sms.from, '46701113311'); assert.equal(sms.message, 'from the reference client'); - await sms.sendResp(); }); }); diff --git a/test/message-class.test.ts b/test/message-class.test.ts index e613139..82a29fd 100644 --- a/test/message-class.test.ts +++ b/test/message-class.test.ts @@ -1,19 +1,19 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import type { PduObjectInput } from '../src/pdu.ts'; -import type { SendSmsDeps } from '../src/send-sms.ts'; +import type { PduObjectInput } from '../src/wire/pdu.ts'; +import type { SendSmsDeps } from '../src/messages/send-sms.ts'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import type { TestContext } from 'node:test'; import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; import { client } from '../src/client.ts'; import { closeAfter } from './teardown.ts'; import { messageClassOf } from '../src/defs/encodings.ts'; import { paramNumber } from '../src/defs/types.ts'; -import { pduToObj } from '../src/pdu.ts'; +import { pduToObj } from '../src/wire/pdu.ts'; import { server } from '../src/server.ts'; import { silentLog } from '../src/log.ts'; -import { submitSms } from '../src/send-sms.ts'; +import { submitSms } from '../src/messages/send-sms.ts'; const from = '46701113311'; const to = '46709771337'; @@ -27,18 +27,15 @@ 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 { const received: Sms[] = []; - const { err, server: smpp } = await server({ port: 0 }); + const { err, server: smpp } = await server({ + onSms: sms => { received.push(sms); }, + 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 +213,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 GSM7, LATIN1, UCS2/); assert.deepEqual(sent.smsIds, []); } diff --git a/test/message.test.ts b/test/message.test.ts index 37392a4..9159ad9 100644 --- a/test/message.test.ts +++ b/test/message.test.ts @@ -8,7 +8,7 @@ import { smppDate, smppTime, splitMessage, -} from '../src/message.ts'; +} from '../src/messages/message.ts'; // Through the public surface: an application handed a PduObject needs this same answer. import { encodings, isEncodingName, messageOctets, objToPdu, pduToObj } from '../src/index.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, 'GSM7'); assert.equal(encodeMessage('تست').encoding, 'UCS2'); }); @@ -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[] = ['GSM7', '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/messaging-mode.test.ts b/test/messaging-mode.test.ts index 4ac57ee..44e4efb 100644 --- a/test/messaging-mode.test.ts +++ b/test/messaging-mode.test.ts @@ -1,16 +1,16 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import type { PduObjectInput } from '../src/pdu.ts'; -import type { SendSmsDeps } from '../src/send-sms.ts'; +import type { PduObjectInput } from '../src/wire/pdu.ts'; +import type { SendSmsDeps } from '../src/messages/send-sms.ts'; import type { Session } from '../src/session.ts'; import type { SubmitMessagingMode } from '../src/defs/constants.ts'; import type { TestContext } from 'node:test'; import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; import { consts, submitMessagingModes } from '../src/defs/constants.ts'; import { paramNumber } from '../src/defs/types.ts'; -import { pduToObj } from '../src/pdu.ts'; +import { pduToObj } from '../src/wire/pdu.ts'; import { silentLog } from '../src/log.ts'; -import { submitSms } from '../src/send-sms.ts'; +import { submitSms } from '../src/messages/send-sms.ts'; const from = '46701113311'; const to = '46709771337'; diff --git a/test/operator-receipts.test.ts b/test/operator-receipts.test.ts index 36dd655..2fa7d5d 100644 --- a/test/operator-receipts.test.ts +++ b/test/operator-receipts.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import type { Dlr, Receipt } from '../src/dlr.ts'; +import type { Dlr, Receipt } from '../src/messages/dlr.ts'; import type { MessageDlr } from '../src/session.ts'; -import type { PduObject, TlvInputs } from '../src/pdu.ts'; +import type { PduObject, TlvInputs } from '../src/wire/pdu.ts'; import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; import { consts } from '../src/defs/constants.ts'; -import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts'; -import { objToPdu, pduToObj } from '../src/pdu.ts'; +import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/messages/dlr.ts'; +import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; /** * Receipt bodies as commercial operators document them, from `interop-tests/research/operator-quirks.md` diff --git a/test/pdu-framer.test.ts b/test/pdu-framer.test.ts index 1461b43..c7803d2 100644 --- a/test/pdu-framer.test.ts +++ b/test/pdu-framer.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import { PduFramer } from '../src/pdu-framer.ts'; -import { objToPdu } from '../src/pdu.ts'; +import { PduFramer } from '../src/wire/pdu-framer.ts'; +import { objToPdu } from '../src/wire/pdu.ts'; function pdu(seqNr: number): Buffer { const { buffer } = objToPdu({ cmdName: 'enquire_link', seqNr }); diff --git a/test/pdu.test.ts b/test/pdu.test.ts index c773865..6322734 100644 --- a/test/pdu.test.ts +++ b/test/pdu.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts'; -import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; +import { PduRefusedError, refusalAnswer } from '../src/wire/pdu-refusal.ts'; +import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/wire/pdu.ts'; import { paramText } from '../src/defs/types.ts'; import { isTlvName, tlvsById } from '../src/defs/tlvs.ts'; diff --git a/test/raw-pdus.ts b/test/raw-pdus.ts index 46ae6f4..0173069 100644 --- a/test/raw-pdus.ts +++ b/test/raw-pdus.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; -import type { PduObjectInput } from '../src/pdu.ts'; -import { objToPdu } from '../src/pdu.ts'; +import type { PduObjectInput } from '../src/wire/pdu.ts'; +import { objToPdu } from '../src/wire/pdu.ts'; /** The octets a test writes straight to a socket, which objToPdu builds for every valid PDU. */ export function pduBytes(input: PduObjectInput): Buffer { diff --git a/test/readme.test.ts b/test/readme.test.ts index f2198a5..0faf476 100644 --- a/test/readme.test.ts +++ b/test/readme.test.ts @@ -1,15 +1,16 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import type { Dlr } from '../src/dlr.ts'; +import type { Dlr } from '../src/messages/dlr.ts'; import type { Session } from '../src/session.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import type { SmppLog } from '../src/log.ts'; import type { SmppServer } from '../src/server.ts'; +import type { SmsHandler } from '../src/session/session-options.ts'; import type { TestContext } from 'node:test'; -import { PduRefusedError } from '../src/pdu-refusal.ts'; +import { PduRefusedError } from '../src/wire/pdu-refusal.ts'; import { client } from '../src/client.ts'; import { closeAfter } from './teardown.ts'; -import { isCommand, objToPdu } from '../src/pdu.ts'; +import { isCommand, objToPdu } from '../src/wire/pdu.ts'; import { server } from '../src/server.ts'; function once(register: (resolve: (value: T) => void) => void): Promise { @@ -25,22 +26,30 @@ function once(register: (resolve: (value: T) => void) => void): Promise { }); } -/** The README's examples listen on the documented default port, so one server runs at a time. */ -async function answeringServer(t: TestContext): Promise { - const { err, server: smpp } = await server(); +type Deferred = { promise: Promise; resolve: (value: T) => void }; - assert.equal(err, undefined); - assert.ok(smpp); - closeAfter(t, smpp); +/** A promise settled from the outside, for a handler that has to exist before what fires it. */ +function deferred(): Deferred { + let resolve: (value: T) => void = () => undefined; + const promise = once(settle => { resolve = settle; }); + + return { promise, resolve }; +} - smpp.on('session', session => { - session.on('sms', async sms => { - await sms.sendResp(); +/** The README's examples listen on the documented default port, so one server runs at a time. */ +async function answeringServer(t: TestContext, onSms?: SmsHandler): Promise { + const { err, server: smpp } = await server({ + onSms: async sms => { + onSms?.(sms); if (sms.dlr) await sms.sendDlr(); - }); + }, }); + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + return smpp; } @@ -121,10 +130,10 @@ describe('README: Client', () => { }); test('the documented sending options', async t => { - const smpp = await answeringServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const incoming = deferred(); + + await answeringServer(t, incoming.resolve); + const { err, session } = await client(); if (err) throw err; @@ -132,7 +141,7 @@ describe('README: Client', () => { const { signal } = new AbortController(); const [sms, sent] = await Promise.all([ - incoming, + incoming.promise, session.sendSms({ dlr: true, encoding: 'UCS2', @@ -154,15 +163,20 @@ describe('README: Client', () => { test('receiving an inbound message on a client session', async t => { const smpp = await answeringServer(t); const bound = once(resolve => { smpp.on('session', resolve); }); - const { err, session } = await client(); + const incoming = deferred(); + const { err, session } = await client({ + onSms: sms => { + // sms.from, sms.to, sms.message, sms.smsId + incoming.resolve(sms); + }, + }); if (err) throw err; closeAfter(t, session); - const incoming = once(resolve => { session.on('sms', resolve); }); const peer = await bound; - void peer.send({ + const delivered = peer.send({ cmdName: 'deliver_sm', params: { destination_addr: '46709771337', @@ -171,29 +185,25 @@ describe('README: Client', () => { }, }); - const sms = await incoming; - - await sms.sendResp(); + const sms = await incoming.promise; assert.equal(sms.message, 'inbound hello'); + assert.equal((await delivered).pduObj?.cmdStatus, 'ESME_ROK', 'answered before the handler ran'); }); }); 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: sms => { + // sms.from, sms.to, sms.message, sms.dlr, sms.smsId, sms.session received.push(sms.message); - await sms.sendResp(); - }); + }, }); + if (err) throw err; + + closeAfter(t, smpp); const { err: clientErr, session } = await client(); @@ -208,34 +218,26 @@ describe('README: Server', () => { }); test('with authentication and delivery reports', async t => { + let userData: unknown; const { err, server: smpp } = await server({ + // Replace with your own auth. Returning an object attaches it to session.userData. 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 => { - answeredOnArrival = sms.answeredOnArrival; - - if (sms.answeredOnArrival) { - await sms.sendResp(); // multipart: only releases the shutdown drain - } else { - await sms.sendResp(); // ESME_ROK with a generated id - } + onSms: async sms => { + // Already answered ESME_ROK under sms.smsId; sms.session.userData is what authenticate returned. + userData = sms.session.userData; if (sms.dlr) { - await sms.sendDlr(); + await sms.sendDlr(); // same as sms.sendDlr('DELIVERED') } - }); + }, }); + if (err) throw err; + + closeAfter(t, smpp); assert.equal(smpp.port, 2775); @@ -259,11 +261,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 +277,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..5c1157e 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[0] = {}) { - const { err, server: smpp } = await server({ port: 0 }); +async function linked( + t: TestContext, + options: Parameters[0] = {}, + serverOptions: Parameters[0] = {}, +) { + const { err, server: smpp } = await server({ ...serverOptions, port: 0 }); assert.equal(err, undefined); assert.ok(smpp); @@ -95,11 +99,11 @@ 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 }); + const { peer, session } = await linked(t, { responseTimeout: 200 }, { + onSms: () => { throw new Error('listener exploded'); }, + }); const failed = once(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' }); const reported = await raceWithin(2000, failed); diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 6b36279..a47c6d6 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1,42 +1,46 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import test, { describe } from 'node:test'; -import type { Collected, LostGroup } from '../src/reassembly.ts'; -import type { Dlr } from '../src/dlr.ts'; +import type { Collected, LostGroup } from '../src/messages/reassembly.ts'; +import type { Dlr } from '../src/messages/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 { IncomingRequestsOptions } from '../src/session/incoming-requests.ts'; +import type { RunningHandlersOptions } from '../src/session/running-handlers.ts'; import type { MessageState } from '../src/defs/constants.ts'; +import type { OnRequest } from '../src/session/session-options.ts'; import type { MessageDlr } 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'; +import type { PduObject, PduObjectInput } from '../src/wire/pdu.ts'; +import type { Result, VoidResult } from '../src/result.ts'; +import type { SendSmsResult } from '../src/messages/send-sms.ts'; import type { SmppLog } from '../src/log.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import type { SmppServer } from '../src/server.ts'; import type { TestContext } from 'node:test'; -import { HeldMessages } from '../src/held-messages.ts'; -import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts'; -import { UnansweredError } from '../src/unanswered-error.ts'; -import { createSms } from '../src/sms.ts'; -import { LinkLife } from '../src/link-life.ts'; -import { SendWindow } from '../src/send-window.ts'; -import { Reassembler, decodeSegments } from '../src/reassembly.ts'; +import { RunningHandlers } from '../src/session/running-handlers.ts'; +import { IncomingRequests, refusedSegmentStatus } from '../src/session/incoming-requests.ts'; +import { UnansweredError } from '../src/messages/unanswered-error.ts'; +import { createSms } from '../src/messages/sms.ts'; +import { LinkLife } from '../src/session/link-life.ts'; +import { SendWindow } from '../src/session/send-window.ts'; +import { Reassembler, decodeSegments } from '../src/messages/reassembly.ts'; 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 { DlrMerger } from '../src/messages/dlr-merger.ts'; +import { PduRefusedError } from '../src/wire/pdu-refusal.ts'; +import { isCommand, objToPdu } from '../src/wire/pdu.ts'; +import { bounds } from '../src/defaults.ts'; +import { checkSessionOptions } from '../src/session/session-options.ts'; +import { standsInFor } from '../src/session/bind-direction.ts'; import { client } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; -import { concatOf } from '../src/concat.ts'; +import { concatOf } from '../src/messages/concat.ts'; import { consts } from '../src/defs/constants.ts'; import { errors } from '../src/defs/errors.ts'; import { paramNumber, paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; import { silentLog } from '../src/log.ts'; -import { splitMessage } from '../src/message.ts'; -import { submitSms, submitSmParams } from '../src/send-sms.ts'; +import { splitMessage } from '../src/messages/message.ts'; +import { submitSms, submitSmParams } from '../src/messages/send-sms.ts'; +import { uuidv7 } from '../src/messages/uuid.ts'; async function startServer( t: TestContext, @@ -81,6 +85,19 @@ function delay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms); }); } +type Deferred = { promise: Promise; resolve: (value: T) => void }; + +/** A promise settled from the outside, for a handler that has to exist before what fires it. */ +function deferred(): Deferred { + let resolve: (value: T) => void = () => undefined; + const promise = once(settle => { resolve = settle; }); + + return { promise, resolve }; +} + +/** A hook that keeps every submit_sm unanswered, which is how a test holds the peer's window open. */ +const holdSubmits: OnRequest = (_session, pduObj) => isCommand(pduObj, 'submit_sm'); + /** Undefined where the promise never settled, which is an assertion rather than a hung run. */ function within(ms: number, promise: Promise): Promise { return Promise.race([promise, delay(ms).then((): undefined => undefined)]); @@ -104,9 +121,8 @@ function abortAfter( function incomingOn(session: Session, options: Partial = {}): IncomingRequests { return new IncomingRequests({ 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, }); @@ -170,10 +186,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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const incoming = deferred(); + const smpp = await startServer(t, { onSms: incoming.resolve }); const { session } = await connect(t, smpp); assert.ok(session); @@ -184,11 +198,7 @@ describe('merged delivery reports', () => { session.on('dlr', dlr => perSegment.push(dlr.smsId ?? '')); const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp(); - - return received; - }), + incoming.promise, session.sendSms({ dlr: true, from: '46701113311', @@ -208,10 +218,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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const incoming = deferred(); + const smpp = await startServer(t, { onSms: incoming.resolve }); const { session } = await connect(t, smpp); assert.ok(session); @@ -226,11 +234,7 @@ describe('merged delivery reports', () => { }); const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp(); - - return received; - }), + incoming.promise, session.sendSms({ dlr: true, from: '46701113311', @@ -264,10 +268,8 @@ describe('merged delivery reports', () => { }); test('reports the worst status across the segments', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const incoming = deferred(); + const smpp = await startServer(t, { onSms: incoming.resolve }); const { session } = await connect(t, smpp); assert.ok(session); @@ -275,11 +277,7 @@ describe('merged delivery reports', () => { const merged = once(resolve => { session.on('messageDlr', resolve); }); const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp(); - - return received; - }), + incoming.promise, session.sendSms({ dlr: true, from: '46701113311', @@ -378,18 +376,20 @@ 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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); + const smpp = await startServer(t, { + onRequest: async (peer, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; + + await peer.sendReturn(pduObj, 'ESME_RMSGQFUL'); + + return true; + }, }); const { session } = await connect(t, smpp); assert.ok(session); - const [, sent] = await Promise.all([ - incoming.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })), - session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }), - ]); + const sent = await session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }); assert.ok(sent.err instanceof Error); assert.match(sent.err.message, /ESME_RMSGQFUL/); @@ -602,17 +602,8 @@ 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 => { - messages.push(sms.message); - void sms.sendResp(); - }); - }); - + const smpp = await startServer(t, { onSms: sms => { messages.push(sms.message); } }); const { err, session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); assert.equal(err, undefined); @@ -652,13 +643,8 @@ describe('reconnect', () => { }); test('merges the receipts of a multipart message across a drop', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', sms => { - resolve(sms); - void sms.sendResp(); - })); - }); + const incoming = deferred(); + const smpp = await startServer(t, { onSms: incoming.resolve }); const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); assert.ok(session); @@ -669,7 +655,7 @@ describe('reconnect', () => { message: 'x'.repeat(400), to: '46709771337', }); - const sms = await incoming; + const sms = await incoming.promise; assert.deepEqual(sent.smsIds, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`)); @@ -692,41 +678,28 @@ describe('reconnect', () => { assert.equal(report.segments.length, 3); }); - test('refuses to answer a message whose link went, held or already answered', async t => { + test('sends the receipt for a message whose link went on the new link', async t => { const smpp = await startServer(t); - const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + const arrived = deferred(); + const { session } = await connect(t, smpp, { + onSms: arrived.resolve, + reconnect: { maxDelay: 100, minDelay: 20 }, + }); assert.ok(session); - const arrived: Sms[] = []; - const both = once(resolve => { - session.on('sms', sms => { - arrived.push(sms); - - if (arrived.length === 2) resolve(true); - }); + const delivered = await peerOf(smpp).send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46701113311', + short_message: 'answered before the drop', + source_addr: '46709771337', + }, }); - for (const text of ['answered before the drop', 'never answered']) { - void peerOf(smpp).send({ - cmdName: 'deliver_sm', - params: { - destination_addr: '46701113311', - short_message: text, - source_addr: '46709771337', - }, - }); - } - - await both; - - const [answered, held] = arrived; - - assert.ok(answered); - assert.ok(held); - assert.equal(answered.message, 'answered before the drop'); - assert.equal((await answered.sendResp()).err, undefined); + assert.equal(delivered.err, undefined, 'answered on arrival, on the link it arrived on'); + const sms = await arrived.promise; const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); await peerOf(smpp).close({ signal: AbortSignal.abort() }); @@ -734,14 +707,10 @@ describe('reconnect', () => { let taken = 0; - // 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 held.sendResp()).err?.message ?? '', /link this message arrived on is gone/); - - assert.equal((await held.sendDlr('DELIVERED')).err, undefined); - assert.equal(taken, 1, 'a refused response reached the new link'); + assert.equal((await sms.sendDlr('DELIVERED')).err, undefined); + assert.equal(taken, 1, 'the receipt reached the new link'); }); test('drops a message whose link went while onRequest was still running', async t => { @@ -749,23 +718,22 @@ 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; + const incoming = incomingOn(session, { + onRequest: async () => { await delay(10); return false; }, + onSms: () => { messages++; }, + }); - session.on('sms', () => { messages++; }); + await incoming.handle(submitPdu(2)); - const handled = incoming.handle(submitPdu(1)); + assert.equal(messages, 1, 'the harness delivers a message whose link stayed'); - link.drop(); + const handled = incoming.handle(submitPdu(1)); + session.sock.destroy(); await handled; - assert.equal(messages, 0); - - await incoming.handle(submitPdu(2)); - - assert.equal(messages, 1, 'the harness delivers a message whose link stayed'); + assert.equal(messages, 1); }); test('does not reconnect after an explicit close', async t => { @@ -1147,28 +1115,47 @@ 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 { + /** Holds the first submit unanswered, so it keeps the send window open; every later one is taken. */ + function holdTheFirst(): { arrived: string[]; first: Latch; options: Parameters[0] } { + const arrived: string[] = []; const first = latch(); + let seen = 0; - smpp.on('session', peer => { - peer.on('sms', async sms => { - arrived.push(sms.message); + return { + arrived, + first, + options: { + onRequest: (_peer, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || seen++ > 0) return false; - if (arrived.length === 1) first.open(); - else await sms.sendResp(); - }); - }); + first.open(); - return first; + return true; + }, + onSms: sms => { arrived.push(sms.message); }, + }, + }; } - 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[] = []; + /** Resolves with the submit_sm as it lands, leaving it unanswered. */ + function arrivalOf(): { arrived: Promise; onRequest: OnRequest } { + const landed = deferred(); - smpp.on('session', peer => { peer.on('sms', async sms => { arrived.push(sms.message); await sms.sendResp(); }); }); + return { + arrived: landed.promise, + onRequest: (_peer, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; + landed.resolve(pduObj); + + return true; + }, + }; + } + + test('holds a send issued while the link is down and puts it on the new link', async t => { + const arrived: string[] = []; + const smpp = await startServer(t, { onSms: sms => { arrived.push(sms.message); } }); const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); assert.ok(session); @@ -1187,9 +1174,8 @@ 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 { arrived, first, options } = holdTheFirst(); + const smpp = await startServer(t, options); const { session } = await connect(t, smpp, { maxOutstanding: 1, reconnect: { maxDelay: 100, minDelay: 20 }, @@ -1210,7 +1196,7 @@ describe('sends across a reconnect', () => { assert.equal(dropped.unanswered, 1); assert.equal(resent.err, undefined, 'a request that never reached the socket is not lost with it'); assert.equal(resent.smsIds.length, 1); - assert.deepEqual(arrived, ['first', 'second']); + assert.deepEqual(arrived, ['second'], 'the held one reached the hook alone'); }); test('holds a send issued while the rebind is still binding', async t => { @@ -1228,10 +1214,8 @@ describe('sends across a reconnect', () => { return true; }, + onSms: () => undefined, }); - - 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 +1242,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, { onRequest: holdSubmits }); const { session } = await connect(t, smpp, { responseTimeout: 200 }); assert.ok(session); @@ -1273,8 +1254,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(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); + const { arrived, onRequest } = arrivalOf(); + const smpp = await startServer(t, { onRequest }); const { session } = await connect(t, smpp); assert.ok(session); @@ -1295,8 +1276,8 @@ 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(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); + const { arrived, onRequest } = arrivalOf(); + const smpp = await startServer(t, { onRequest }); const { session } = await connect(t, smpp, { reconnect: false }); assert.ok(session); @@ -1404,10 +1385,10 @@ describe('sends across a reconnect', () => { }); describe('LinkLife', () => { - test('refuses a hold whose deadline has already passed', async () => { + test('refuses a budget already spent when the link comes back', async () => { let now = 0; const link = new LinkLife({ log: silentLog, now: () => now, reconnects: true, timeout: 100 }); - const waitForLink = link.hold(undefined); + const waitForLink = link.budget(undefined); link.drop(); now = 101; @@ -1424,10 +1405,11 @@ describe('LinkLife', () => { link.drop(); const before = timers(); - const held = link.hold(undefined)(); + const held = link.budget(undefined)(); assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point'); + link.attach(); link.open(); assert.deepEqual(await held, {}); @@ -1439,19 +1421,24 @@ describe('LinkLife', () => { link.drop(); - const held = await link.hold(AbortSignal.abort())(); + const held = await link.budget(AbortSignal.abort())(); assert.match(held.err?.message ?? '', /Aborted while waiting for a link/); }); - test('awaits the next link only while down with one on its way', () => { + test('carries requests from the bind on, and awaits the next link only while one is on its way', () => { const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); + assert.equal(link.isUp(), false, 'attached, not yet bound'); + assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound'); + link.open(); + assert.equal(link.isUp(), true, 'bound'); assert.equal(link.awaitsNextLink(), false, 'up'); link.drop(); assert.equal(link.awaitsNextLink(), true, 'down, returning'); + link.open(); + assert.equal(link.isUp(), false, 'a bind on no socket opens nothing'); link.attach(); - assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound'); link.open(); assert.equal(link.awaitsNextLink(), false, 'reopened'); link.drop(); @@ -1462,18 +1449,17 @@ describe('LinkLife', () => { assert.equal(link.awaitsNextLink(), false, 'ended'); }); - test('drops an attached link once, counts each drop, and names the event it warrants', () => { + test('drops an attached link once', () => { const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); - const generation = link.generation(); - assert.equal(link.drop(), 'disconnected'); - assert.equal(link.drop(), undefined, 'already down'); - assert.equal(link.generation(), generation + 1); + assert.equal(link.retrying(), true); + assert.equal(link.drop(), true); + assert.equal(link.drop(), false, 'already down'); link.attach(); link.stop(); - assert.equal(link.drop(), 'close', 'a new link drops again, with none to follow it'); - assert.equal(link.generation(), generation + 2); - assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).drop(), 'close'); + assert.equal(link.retrying(), false, 'a new link drops again, with none to follow it'); + assert.equal(link.drop(), true); + assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).retrying(), false); }); test('releases a held request with the reason once the link ends', async () => { @@ -1481,7 +1467,7 @@ describe('LinkLife', () => { link.drop(); - const held = link.hold(undefined)(); + const held = link.budget(undefined)(); link.end(); @@ -1497,9 +1483,10 @@ describe('SendWindow', () => { const window = new SendWindow({ limit: 1, log: silentLog }); const controller = new AbortController(); - assert.deepEqual(await window.acquire(undefined), {}); + assert.equal(window.take(), true); + assert.equal(window.take(), false, 'a full window hands out nothing on the spot'); - const queued = window.acquire(controller.signal); + const queued = window.wait(controller.signal); controller.abort(); @@ -1512,24 +1499,24 @@ describe('SendWindow', () => { const window = new SendWindow({ limit: 1, log: silentLog }); const controller = new AbortController(); - await window.acquire(undefined); + window.take(); - const abandoned = window.acquire(controller.signal); + const abandoned = window.wait(controller.signal); controller.abort(); await abandoned; window.release(); assert.equal(window.unfinished(), 0, 'the slot is free, not stranded on the waiter that left'); - assert.deepEqual(await window.acquire(undefined), {}, 'so the next send takes it at once'); + assert.equal(window.take(), true, 'so the next send takes it at once'); }); test('takes no slot for a signal that was already aborted', async () => { const window = new SendWindow({ limit: 1, log: silentLog }); - await window.acquire(undefined); + window.take(); - const refused = await window.acquire(AbortSignal.abort()); + const refused = await window.wait(AbortSignal.abort()); assert.match(refused.err?.message ?? '', /Aborted while waiting for a send window slot/); assert.equal(window.unfinished(), 1); @@ -1537,203 +1524,204 @@ 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)]; - } +describe('running handler bounds', () => { + function handlersOn(t: TestContext, options: Partial = {}): RunningHandlers { + const handlers = new RunningHandlers({ + log: silentLog, + max: 2, + onFailure: () => undefined, + timeout: 10_000, + ...options, + }); - function offer(held: HeldMessages, seqNr: number): MessageHold { - const hold = held.offer(message(seqNr)); + t.after(() => { handlers.release(); }); - assert.ok(hold); + return handlers; + } - return hold; + function smsOn(session: Session): Sms { + return createSms({ pduObjs: [submitPdu(1)], session, smsId: uuidv7() }, () => Promise.resolve({ err: new Error('never sent') })); } - /** Offers to a session with a listener, so an offer is held rather than released as untaken. */ - function heldOn( - t: TestContext, - options: Pick, - ): HeldMessages { - const session = new Session({ sock: new net.Socket() }); + /** Runs a handler that finishes only when the latch opens. */ + function runHeld(handlers: RunningHandlers, session: Session): Latch { + const finish = latch(); - closeAfter(t, session); - session.on('sms', () => undefined); + handlers.run(smsOn(session), () => finish.passed); - return new HeldMessages({ - ...options, - link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }), - log: silentLog, - sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }), - session, - }); + return finish; } - 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); + test('is full at its count, until a handler finishes by any way out', async t => { + const session = new Session({ sock: new net.Socket() }); - offer(held, 2); + closeAfter(t, session); - 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); + const failures: string[] = []; + const handlers = handlersOn(t, { onFailure: err => { failures.push(err.message); } }); + const first = runHeld(handlers, session); - held.clear(); - }); + assert.equal(handlers.full(), false); - // 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 => { - let now = 0; - const held = heldOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); - const answered = offer(held, 1); + const second = runHeld(handlers, session); + + assert.equal(handlers.size, 2); + assert.equal(handlers.full(), true); - assert.equal(held.full(), false); - offer(held, 2); - assert.equal(held.full(), true); + first.open(); + await delay(1); + assert.equal(handlers.full(), false, 'after one resolved'); - answered.release(); - assert.equal(held.full(), false, 'after a release'); - offer(held, 3); + handlers.run(smsOn(session), () => Promise.reject(new Error('gave up'))); + await delay(1); + assert.equal(handlers.full(), false, 'after one rejected'); + assert.deepEqual(failures, ['gave up']); - now = 20_000; - held.sweep(); - now = 0; - assert.equal(held.full(), false, 'after a sweep'); - offer(held, 4); - offer(held, 5); + handlers.run(smsOn(session), () => { throw new Error('exploded'); }); + await delay(1); + assert.equal(handlers.full(), false, 'after one threw'); + assert.deepEqual(failures, ['gave up', 'exploded']); - held.clear(); - assert.equal(held.full(), false, 'after a clear'); + second.open(); + await delay(1); + assert.equal(handlers.size, 0); }); - // Dropping one the application still holds frees nothing, and the drain stops waiting for it. test('refuses what arrives past the bound with a status that asks the peer to retry', async t => { const session = new Session({ sock: new net.Socket() }); closeAfter(t, session); const warnings: string[] = []; - const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } }); + const finished: Latch[] = []; + const incoming = incomingOn(session, { + log: { ...silentLog, warn: message => { warnings.push(message); } }, + onSms: () => { + const finish = latch(); + + finished.push(finish); + + return finish.passed; + }, + }); const answers: (ErrorName | undefined)[] = []; - const received: Sms[] = []; session.sendReturn = (_pdu, status) => { answers.push(status); return Promise.resolve({}); }; - session.on('sms', sms => { received.push(sms); }); - for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) { + for (let seqNr = 1; seqNr <= bounds.maxRunningHandlers; seqNr++) { await incoming.handle(submitPdu(seqNr)); } - assert.equal(received.length, defaults.maxHeldMessages); + assert.equal(incoming.running, bounds.maxRunningHandlers); - await incoming.handle(submitPdu(defaults.maxHeldMessages + 1)); + await incoming.handle(submitPdu(bounds.maxRunningHandlers + 1)); await incoming.handle(segment(7, 1, 2)); - assert.equal(received.length, defaults.maxHeldMessages); - assert.deepEqual(answers, ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']); + assert.equal(finished.length, bounds.maxRunningHandlers); + assert.deepEqual(answers.slice(-2), ['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); }); + finished[0]?.open(); + await delay(1); await incoming.handle(segment(7, 2, 2)); assert.equal(answers.at(-1), 'ESME_ROK'); - assert.equal(received.length, defaults.maxHeldMessages); + assert.equal(finished.length, bounds.maxRunningHandlers); - // 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)); + // A peer keeping its window full crosses the bound on every finished handler, and that is still one warning. + finished[1]?.open(); + await delay(1); + await incoming.handle(submitPdu(bounds.maxRunningHandlers + 2)); + await incoming.handle(submitPdu(bounds.maxRunningHandlers + 3)); + await incoming.handle(submitPdu(bounds.maxRunningHandlers + 4)); assert.equal(answers.at(-1), 'ESME_RTHROTTLED'); assert.equal(warnings.length, 1); - incoming.clear(); + + for (const finish of finished) finish.open(); + + incoming.release(); }); - test('holds a message detached from the chunk it was read from', async t => { + test('refuses a message where no handler takes them, so the peer is not left waiting', async t => { const session = new Session({ sock: new net.Socket() }); closeAfter(t, session); const incoming = incomingOn(session); - const chunk = Buffer.alloc(64 * 1024); - const carried = submitPdu(1); - let received: Sms | undefined; + const answers: (ErrorName | undefined)[] = []; - session.on('sms', sms => { received = sms; }); - await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } }); + session.sendReturn = (_pdu, status) => { + answers.push(status); - const retained = received?.pduObjs[0]?.params.short_message; + return Promise.resolve({}); + }; + await incoming.handle(submitPdu(1)); + await incoming.handle(segment(7, 1, 2)); - assert.ok(Buffer.isBuffer(retained)); - assert.notEqual(retained.buffer, chunk.buffer); - incoming.clear(); + assert.deepEqual(answers, ['ESME_RX_P_APPN', 'ESME_RX_P_APPN']); }); - test('gives up on a message the application never answers', t => { - let now = 0; - const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + test('hands the handler a message detached from the chunk it was read from', async t => { + const session = new Session({ sock: new net.Socket() }); - offer(held, 1); - now = 61; + closeAfter(t, session); - // The next message sweeps the one that expired, so only the new one is still waited for. - offer(held, 2); + let received: Sms | undefined; + const incoming = incomingOn(session, { onSms: sms => { received = sms; } }); + const chunk = Buffer.alloc(64 * 1024); + const carried = submitPdu(1); - assert.equal(held.size, 1); + await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } }); + + const retained = received?.pduObjs[0]?.params.short_message; - held.clear(); + assert.ok(Buffer.isBuffer(retained)); + assert.notEqual(retained.buffer, chunk.buffer); }); - // 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 => { - let now = 0; - const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + test('gives up on a handler that never finishes', async t => { + const session = new Session({ sock: new net.Socket() }); - offer(held, 1); + closeAfter(t, session); - const waiting = held.idle(1000, undefined); + const warnings: string[] = []; + const handlers = handlersOn(t, { + log: { ...silentLog, warn: message => { warnings.push(message); } }, + timeout: 30, + }); - now = 61; - held.sweep(); + handlers.run(smsOn(session), () => new Promise(() => undefined)); - assert.equal(await waiting, 0); + assert.equal(handlers.size, 1); + await delay(50); + assert.equal(handlers.size, 0); + assert.equal(warnings.length, 1); }); -}); -describe('sendResp()', () => { - // A response the wire never carried leaves the peer owed one, so nothing may count it answered. - test('does not count a response that never reached the wire as an answer', async t => { + // Without this the drain sits out its whole budget before returning what a deadline already settled. + test('wakes a waiting drain when the last handler finishes or expires', async t => { const session = new Session({ sock: new net.Socket() }); closeAfter(t, session); - let answered = 0; + const handlers = handlersOn(t, { timeout: 30 }); + const finish = runHeld(handlers, session); - session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') }); + handlers.run(smsOn(session), () => new Promise(() => undefined)); - const sms = createSms({ - pduObjs: [submitPdu(1)], - session, - }, { - answered: () => { answered++; }, - lostLink: () => false, - send: () => Promise.resolve({ err: new Error('never sent') }), - }); + const waiting = handlers.idle(1000, undefined); - assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); - assert.equal(answered, 0); + finish.open(); + await delay(1); + assert.equal(handlers.size, 1, 'the one that never finishes is still counted'); + + assert.equal(await waiting, 0); }); }); @@ -1748,20 +1736,17 @@ describe('sendDlr()', () => { const sms = createSms({ pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)], session, - }, { - answered: () => undefined, - lostLink: () => false, - send: () => { - call++; + smsId: uuidv7(), + }, () => { + call++; - if (call === 1) return Promise.resolve({ pduObj: submitPdu(1, 'ESME_RX_T_APPN') }); + if (call === 1) return Promise.resolve({ pduObj: submitPdu(1, 'ESME_RX_T_APPN') }); - if (call === 2) { - return Promise.resolve({ err: new UnansweredError(new Error('nothing came back')) }); - } + if (call === 2) { + return Promise.resolve({ err: new UnansweredError(new Error('nothing came back')) }); + } - return Promise.resolve({ pduObj: submitPdu(3) }); - }, + return Promise.resolve({ pduObj: submitPdu(3) }); }); const report = await sms.sendDlr('DELIVERED'); @@ -2351,7 +2336,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: 'GSM7', multipart: true }, ), }); @@ -2372,39 +2357,34 @@ 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(resolve => { - smpp.on('session', bound => bound.on('sms', sms => { + const incoming = deferred(); + const smpp = await startServer(t, { + onSms: sms => { messages.push(sms); - resolve(sms); - })); + incoming.resolve(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.promise; assert.equal(sms.message, text); assert.equal(sms.pduObjs.length, answers.length); assert.equal(messages.length, 1, 'the application sees one message, not one per segment'); - assert.equal(sms.answeredOnArrival, true); assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); assert.deepEqual( answers.map(answer => paramText(answer.params.message_id)), answers.map((_answer, index) => `${sms.smsId}-${String(index + 1)}`), ); - assert.deepEqual(await sms.sendResp(), {}); }); - // 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(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); - }); + test('answers a single-segment message on arrival, with the id the handler reads', async t => { + const incoming = deferred(); + const smpp = await startServer(t, { onSms: incoming.resolve }); const { session } = await connect(t, smpp); assert.ok(session); @@ -2413,54 +2393,24 @@ describe('a peer that sends the next segment only once the last one is answered' cmdName: 'submit_sm', params: { destination_addr: '46709771337', - short_message: 'one segment, answered by the application', + short_message: 'one segment, answered on arrival', source_addr: '46701113311', }, }); - const sms = await incoming; - - assert.equal(await within(150, submitted), undefined, 'nothing may answer for the application'); - assert.equal(sms.answeredOnArrival, false); - assert.deepEqual(await sms.sendResp({ smsId: '0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21' }), {}); - - const answered = await submitted; - - assert.ok(answered.pduObj); - assert.equal( - paramText(answered.pduObj.params.message_id), - '0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21', - ); - }); - - test('refuses an id and a refusing status for segments already on the wire', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); - }); - const { session } = await connect(t, smpp, { responseTimeout: 1000 }); - - assert.ok(session); - await submitSerially(session, 0x2B); + const sms = await incoming.promise; + const response = await submitted; - const sms = await incoming; - const named = await sms.sendResp({ smsId: '0199e0ea-1f3d-7ab4-8c21-6d4e5f0a9b73' }); - const refused = await sms.sendResp({ status: 'ESME_RMSGQFUL' }); - - assert.match(named.err?.message ?? '', /fixed when its first segment arrived/); - assert.match(refused.err?.message ?? '', /onRequest/); - assert.deepEqual(await sms.sendResp({ status: 'ESME_ROK' }), {}); - assert.equal(sms.answeredOnArrival, true); + assert.ok(response.pduObj); + assert.equal(response.pduObj.cmdStatus, 'ESME_ROK'); + assert.equal(paramText(response.pduObj.params.message_id), sms.smsId); }); 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(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 }); @@ -2480,7 +2430,10 @@ describe('a peer that sends the next segment only once the last one is answered' test('reports a drop once as disconnected when a listener closes the session over the segments it lost', async t => { const smpp = await startServer(t); - const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + const { session } = await connect(t, smpp, { + onSms: () => undefined, + reconnect: { maxDelay: 100, minDelay: 20 }, + }); assert.ok(session); @@ -2500,7 +2453,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: 'GSM7', multipart: true }, ), }); @@ -2511,30 +2464,32 @@ describe('a peer that sends the next segment only once the last one is answered' assert.deepEqual(events, ['disconnected', 'close']); }); - 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(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); + test('close() still waits for the handler of a concatenated message', async t => { + const incoming = deferred(); + const smpp = await startServer(t, { + onSms: sms => { + incoming.resolve(sms); + + return new Promise(() => undefined); + }, + shutdownTimeout: 50, }); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); assert.ok(session); await submitSerially(session, 0x2D); - await incoming; + await incoming.promise; 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 handler\(s\) still running/); }); - // 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(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); - }); + test('answers a one-part concatenated message with the base id, as a whole message', async t => { + const incoming = deferred(); + const smpp = await startServer(t, { onSms: incoming.resolve }); const { session } = await connect(t, smpp, { responseTimeout: 1000 }); assert.ok(session); @@ -2551,24 +2506,19 @@ 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.promise; assert.equal(answered.err, undefined); assert.ok(answered.pduObj); assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); assert.equal(paramText(answered.pduObj.params.message_id), sms.smsId); assert.equal(sms.pduObjs.length, 1); - assert.equal(sms.answeredOnArrival, true); - assert.deepEqual(await sms.sendResp(), {}); }); // 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 }); assert.ok(session); @@ -2594,11 +2544,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 }); assert.ok(session); @@ -2662,13 +2609,10 @@ describe('AbortSignal on a send', () => { t: TestContext, options: Parameters[0] = {}, ): Promise { - const smpp = await startServer(t); + const smpp = await startServer(t, { onRequest: holdSubmits }); const onWire = once(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 +2660,25 @@ 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(resolve => { smpp.on('session', bound => bound.on('sms', resolve)); }); + const holding = deferred<{ peer: Session; pduObj: PduObject }>(); let firstTaken = false; - - smpp.on('session', bound => { - bound.on('sms', async sms => { - if (firstTaken) await sms.sendResp(); + const smpp = await startServer(t, { + onRequest: (peer, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || firstTaken) return false; firstTaken = true; - }); - }); + holding.resolve({ peer, pduObj }); + return true; + }, + onSms: () => undefined, + }); 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.promise; const controller = new AbortController(); const abandoned = session.sendSms( { from: '46701113311', message: 'abandoned in the queue', to: '46709771337' }, @@ -2748,7 +2693,7 @@ describe('AbortSignal on a send', () => { assert.ok(gaveUp, 'the waiter that gave up must settle before the slot it left is freed'); // Any other error means it never reached the queue, so there was no waiter to strand. assert.match(gaveUp.err?.message ?? '', /Aborted while waiting for a send window slot/); - await held.sendResp(); + await held.peer.sendReturn(held.pduObj); const following = await within(1000, session.sendSms({ from: '46701113311', @@ -2762,27 +2707,60 @@ describe('AbortSignal on a send', () => { }); describe('graceful shutdown', () => { + type HeldSubmit = { answer: (smsId: string) => Promise; pduObj: PduObject }; + + /** A submit the server holds unanswered through onRequest, so the client has a request in flight. */ async function submitInFlight( t: TestContext, options: Parameters[0] = {}, serverOptions: Parameters[0] = {}, - message = 'answer me', ) { - const smpp = await startServer(t, serverOptions); - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', resolve)); + const held = deferred(); + const smpp = await startServer(t, { + ...serverOptions, + onRequest: (peer, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; + + held.resolve({ answer: smsId => peer.sendReturn(pduObj, 'ESME_ROK', { message_id: smsId }), pduObj }); + + return true; + }, }); const { session } = await connect(t, smpp, options); assert.ok(session); + const sent = session.sendSms({ from: '46701113311', message: 'answer me', to: '46709771337' }); + + return { held: await held.promise, sent, session, smpp }; + } + + /** A message whose onSms handler is still running, until the latch opens. */ + async function handlerRunning( + t: TestContext, + serverOptions: Parameters[0] = {}, + message = 'handle me', + ) { + const arrived = deferred(); + const release = latch(); + const smpp = await startServer(t, { + ...serverOptions, + onSms: async sms => { + arrived.resolve(sms); + await release.passed; + }, + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + const sent = session.sendSms({ from: '46701113311', message, to: '46709771337' }); - return { sent, session, sms: await incoming, smpp }; + return { release, sent, session, sms: await arrived.promise, smpp }; } test('close() waits out a submit already on the wire and refuses new ones', async t => { - const { sent, session, sms } = await submitInFlight(t); + const { held, sent, session } = await submitInFlight(t); const closed = session.close(); const refused = await session.sendSms({ from: '46701113311', @@ -2793,7 +2771,7 @@ describe('graceful shutdown', () => { assert.ok(refused.err instanceof Error); assert.equal(refused.err.message, 'Session is shutting down'); - await sms.sendResp({ smsId: 'answered-while-draining' }); + await held.answer('answered-while-draining'); assert.deepEqual((await sent).smsIds, ['answered-while-draining']); assert.deepEqual(await closed, {}); @@ -2801,73 +2779,95 @@ describe('graceful shutdown', () => { // The drain refuses sends; a response was never a send, and saying so is the more useful answer. test('names a response put through send() as the misuse it is, even mid-shutdown', async t => { - const { sent, session, sms } = await submitInFlight(t); + const { held, sent, session } = await submitInFlight(t); const closing = session.close(); const refused = await session.send({ cmdName: 'submit_sm_resp' }); assert.ok(refused.err instanceof Error); assert.match(refused.err.message, /Use sendReturn\(\)/); - await sms.sendResp({ smsId: 'answered-after-the-misuse' }); + await held.answer('answered-after-the-misuse'); assert.deepEqual((await sent).smsIds, ['answered-after-the-misuse']); assert.deepEqual(await closing, {}); }); test('unbind() waits out a submit already on the wire before it unbinds', async t => { - const { sent, session, sms } = await submitInFlight(t); + const { held, sent, session } = await submitInFlight(t); const unbound = session.unbind(); - await sms.sendResp({ smsId: 'answered-before-unbind' }); + await held.answer('answered-before-unbind'); assert.deepEqual((await sent).smsIds, ['answered-before-unbind']); 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 still running', async t => { + const { release, sent, smpp, sms } = await handlerRunning(t); const closing = peerOf(smpp).close(); await delay(50); - await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' }); + release.open(); assert.deepEqual(await closing, {}); - assert.deepEqual((await sent).smsIds, ['answered-during-the-inbound-drain']); + assert.deepEqual((await sent).smsIds, [sms.smsId]); }); - test('gives up on a message the application never answers', async t => { - const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); + test('gives up on a handler that never finishes', async t => { + const { smpp } = await handlerRunning(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 handler\(s\) still running/); }); - // 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 }); + // The handler's own deadline bounds it, so a shutdown that waits forever still ends. + test('waits for a handler as long as it runs when the shutdown waits forever', async t => { + const { release, smpp } = await handlerRunning(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); + release.open(); + + assert.deepEqual(await closing, {}); + assert.ok(Date.now() - started >= 190); }); // 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 }); - 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: { - destination_addr: '46701113311', - short_message: 'nothing answers this', - source_addr: '46709771337', + test('still ends when the handler half has spent the whole shutdown budget', async t => { + const handling = deferred(); + const smpp = await startServer(t, { + onSms: sms => { + handling.resolve(sms); + + return new Promise(() => undefined); }, + shutdownTimeout: 100, + }); + const arrived = once(resolve => { smpp.on('session', resolve); }); + const peer = net.connect({ port: smpp.port }); + const bind = objToPdu({ cmdName: 'bind_transceiver', params: { password: 'pass', system_id: 'user' }, seqNr: 1 }); + const submit = objToPdu({ + cmdName: 'submit_sm', + params: { destination_addr: '46709771337', short_message: 'never handled', source_addr: '46701113311' }, + seqNr: 2, + }); + + assert.ok(bind.buffer); + assert.ok(submit.buffer); + t.after(() => { peer.destroy(); }); + // The peer submits once bound, and answers nothing itself. + peer.once('data', () => { peer.write(submit.buffer); }); + peer.write(bind.buffer); + + const bound = await arrived; + + await handling.promise; + + const unanswered = bound.send({ + cmdName: 'deliver_sm', + params: { destination_addr: '46701113311', short_message: 'nothing answers this', source_addr: '46709771337' }, }); const closed = await Promise.race([ bound.close(), @@ -2876,14 +2876,13 @@ describe('graceful shutdown', () => { }), ]); - assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/); + assert.match(closed.err?.message ?? '', /1 handler\(s\) still running; 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 sends its receipt before it returns; every segment of it goes out. + test('a receipt a handler sends mid-drain still goes out', async t => { + const { release, sent, session, smpp, sms } = await handlerRunning(t, {}, 'x'.repeat(400)); const received: Dlr[] = []; const receipts = once(resolve => { session.on('dlr', dlr => { @@ -2894,109 +2893,65 @@ describe('graceful shutdown', () => { }); const closing = peerOf(smpp).close(); - assert.deepEqual(await sms.sendResp(), {}); + await delay(20); const receiptSent = await sms.sendDlr('DELIVERED'); const ids = [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`); + release.open(); + assert.equal(receiptSent.err, undefined); assert.deepEqual((await receipts).map(dlr => dlr.smsId), ids); 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, and holds the shutdown up for nothing', async t => { const smpp = await startServer(t, { shutdownTimeout: 30_000 }); const { session } = await connect(t, smpp); assert.ok(session); - const bound = peerOf(smpp); - const arrived = once(resolve => { - bound.on('incomingPduObj', pduObj => { - if (pduObj.cmdName === 'submit_sm') resolve(pduObj); - }); - }); - const sent = session.sendSms({ + const sent = await session.sendSms({ from: '46701113311', message: 'nobody is listening', to: '46709771337', }); - await arrived; - await delay(50); + assert.equal(sent.err?.message, 'submit_sm refused by the peer: ESME_RX_P_APPN'); const started = Date.now(); - assert.deepEqual(await bound.close(), {}); + assert.deepEqual(await peerOf(smpp).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 }); - const failed = once(resolve => { - smpp.on('session', bound => { - bound.on('sessionError', resolve); - bound.on('sms', () => Promise.reject(new Error('the listener gave up'))); - }); + test('a handler that rejected does not hold the shutdown up', async t => { + const failed = deferred(); + const smpp = await startServer(t, { + onSms: () => Promise.reject(new Error('the handler gave up')), + shutdownTimeout: 30_000, }); + + smpp.on('session', bound => { bound.on('sessionError', failed.resolve); }); + const { session } = await connect(t, smpp); assert.ok(session); 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.promise).message, 'the handler gave up'); const started = Date.now(); 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 }); - const failed = once(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'))); - }); - }); - const { session } = await connect(t, smpp); - - assert.ok(session); - - const sent = session.sendSms({ - from: '46701113311', - message: 'two listeners, one gives up', - 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']); - }); - - // 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 => { - 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.equal((await sent).err, undefined, 'the message was answered before the handler ran'); }); test('gives up on a request that outlasts shutdownTimeout', async t => { @@ -3031,8 +2986,7 @@ 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); + const smpp = await startServer(t, { onRequest: holdSubmits }); const onWire = once(resolve => { smpp.on('session', bound => bound.on('incomingPduObj', resolve)); }); @@ -3182,7 +3136,7 @@ describe('graceful shutdown', () => { closeAfter(t, session); const calls: string[] = []; - const incoming = incomingOn(session); + const incoming = incomingOn(session, { onSms: () => undefined }); const close = session.close.bind(session); session.sendReturn = pduObj => { @@ -3208,12 +3162,15 @@ describe('message id notation', () => { } test('correlates a hex submit_sm_resp against a decimal receipt', async t => { - const smpp = await startServer(t); + const smpp = await startServer(t, { + onRequest: async (peer, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; - smpp.on('session', bound => { - bound.on('sms', sms => { void sms.sendResp({ smsId: '1a2b' }); }); - }); + await peer.sendReturn(pduObj, 'ESME_ROK', { message_id: '1a2b' }); + return true; + }, + }); const { session } = await connect(t, smpp, { smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, }); @@ -3238,13 +3195,8 @@ 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(resolve => { - smpp.on('session', bound => bound.on('sms', sms => { - resolve(sms); - void sms.sendResp(); - })); - }); + const incoming = deferred(); + const smpp = await startServer(t, { onSms: incoming.resolve }); const { session } = await connect(t, smpp, { smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, }); @@ -3253,7 +3205,7 @@ describe('message id notation', () => { const merged = once(resolve => { session.on('messageDlr', resolve); }); const sent = await sendOne(session, 'x'.repeat(200)); - const sms = await incoming; + const sms = await incoming.promise; 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..a7db591 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -1,28 +1,29 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import test, { describe } from 'node:test'; -import type { Dlr } from '../src/dlr.ts'; -import type { PduObject, PduObjectInput } from '../src/pdu.ts'; -import type { Sms } from '../src/sms.ts'; +import type { Dlr } from '../src/messages/dlr.ts'; +import type { PduObject, PduObjectInput } from '../src/wire/pdu.ts'; +import type { Sms } from '../src/messages/sms.ts'; import type { ServerOptions, SmppServer } from '../src/server.ts'; import type { SmppLog } from '../src/log.ts'; import type { TestContext } from 'node:test'; import type { VoidResult } from '../src/result.ts'; -import { DlrMerger } from '../src/dlr-merger.ts'; -import { PduFramer } from '../src/pdu-framer.ts'; -import { ReconnectLoop } from '../src/reconnect-loop.ts'; -import { Session, bindCommands } from '../src/session.ts'; -import { checkSessionOptions } from '../src/session-options.ts'; +import { DlrMerger } from '../src/messages/dlr-merger.ts'; +import { PduFramer } from '../src/wire/pdu-framer.ts'; +import { ReconnectLoop } from '../src/session/reconnect-loop.ts'; +import { Session } from '../src/session.ts'; +import { bindCommands } from '../src/session/bind-direction.ts'; +import { checkSessionOptions } from '../src/session/session-options.ts'; import { client } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; import { consts } from '../src/defs/constants.ts'; -import { PduRefusedError } from '../src/pdu-refusal.ts'; -import { isCommand, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; +import { PduRefusedError } from '../src/wire/pdu-refusal.ts'; +import { isCommand, objToPdu, pduReturn, pduToObj } from '../src/wire/pdu.ts'; import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; import { bareTlvHeader, pduBytes, shortened, truncatedTlv, withUnknownCmdId } from './raw-pdus.ts'; import { silentLog } from '../src/log.ts'; -import { splitMessage } from '../src/message.ts'; +import { splitMessage } from '../src/messages/message.ts'; async function startServer( t: TestContext, @@ -53,6 +54,16 @@ function once(register: (resolve: (value: T) => void) => void): Promise { return new Promise(resolve => { register(resolve); }); } +/** An onSms handler and the first message it is handed. */ +function firstSms(): { incoming: Promise; onSms: (sms: Sms) => void } { + let resolveSms: (sms: Sms) => void = () => undefined; + const incoming = once(resolve => { resolveSms = resolve; }); + + return { incoming, onSms: sms => { resolveSms(sms); } }; +} + +const uuidV7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + function delay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms); }); } @@ -507,11 +518,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' }); assert.ok(session); @@ -542,20 +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(resolve => { - smpp.on('session', peer => peer.on('sms', resolve)); - }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp, { bindType: 'transmitter' }); assert.ok(session); const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp(); - - return received; - }), + incoming, session.sendSms({ dlr: true, from: '46701113311', message: 'one way', to: '46709771337' }), ]); const report = await sms.sendDlr(); @@ -585,10 +587,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: () => undefined }); const receiving = await connect(t, smpp, { bindType: 'receiver' }); assert.ok(receiving.session); @@ -619,23 +618,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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp); assert.ok(session); const [sms, sent] = await Promise.all([ - incoming.then(async received => { - const refused = await received.sendResp({ smsId: '' }); - - assert.ok(refused.err instanceof Error); - await received.sendResp({ smsId: 'fixed-id' }); - - return received; - }), + incoming, session.sendSms({ from: 'MyBrand', message: 'hello world', to: '46709771337' }), ]); @@ -643,9 +633,9 @@ describe('sending', () => { assert.equal(sms.to, '46709771337'); assert.equal(sms.message, 'hello world'); assert.equal(sms.dlr, false); - assert.equal(sms.smsId, 'fixed-id'); + assert.match(sms.smsId, uuidV7); assert.equal(sent.err, undefined); - assert.deepEqual(sent.smsIds, ['fixed-id']); + assert.deepEqual(sent.smsIds, [sms.smsId]); // 0.4.0 sent TON 1 for every sender, including alphanumeric ones, which require TON 5. const submitted = sms.pduObjs[0]; @@ -656,21 +646,15 @@ describe('sending', () => { }); test('reassembles a long SMS and answers every segment', async t => { - const smpp = await startServer(t); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const message = 'Lorem ipsum dolor sit amet, '.repeat(20); - const incoming = once(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 => { - await received.sendResp(); - - return received; - }), + incoming, session.sendSms({ from: '46701113311', message, to: '46709771337' }), ]); @@ -682,43 +666,31 @@ describe('sending', () => { }); test('carries a UCS2 message through unchanged', async t => { - const smpp = await startServer(t); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const message = 'räksmörgås تست 一'; - const incoming = once(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 => { - await received.sendResp(); - - return received; - }), + incoming, session.sendSms({ from: '46701113311', message, to: '46709771337' }), ]); assert.equal(sms.message, message); - assert.match(sms.smsId, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.match(sms.smsId, uuidV7); }); test('marks a flash message without losing the UCS2 alphabet', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp); assert.ok(session); const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp(); - - return received; - }), + incoming, session.sendSms({ flash: true, from: '46701113311', message: 'تست', to: '46709771337' }), ]); @@ -729,20 +701,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(resolve => { - smpp.on('session', peer => peer.on('sms', resolve)); - }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp); assert.ok(session); const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp(); - - return received; - }), + incoming, session.sendSms({ destinationAddrNpi: consts.NPI.ISDN, destinationAddrTon: consts.TON.NATIONAL, @@ -767,21 +733,21 @@ describe('sending', () => { describe('receiving', () => { async function inbound( t: TestContext, - options: ServerOptions = {}, - ): Promise<{ peer: Session; session: Session }> { - const smpp = await startServer(t, options); + options: { onSms?: (sms: Sms) => void; server?: ServerOptions } = {}, + ): Promise<{ incoming: Promise; peer: Session; session: Session }> { + const first = firstSms(); + const smpp = await startServer(t, options.server ?? {}); const bound = once(resolve => { smpp.on('session', resolve); }); - const { session } = await connect(t, smpp); + const { session } = await connect(t, smpp, { onSms: options.onSms ?? first.onSms }); assert.ok(session); - return { peer: await bound, session }; + return { incoming: first.incoming, peer: await bound, session }; } test('hands a client a deliver_sm that is not a delivery receipt', async t => { - const { peer, session } = await inbound(t); - const incoming = once(resolve => { session.on('sms', resolve); }); + const { incoming, peer } = await inbound(t); const delivered = peer.send({ cmdName: 'deliver_sm', params: { @@ -797,8 +763,6 @@ describe('receiving', () => { assert.equal(sms.to, '46709771337'); assert.equal(sms.message, 'inbound hello'); - await sms.sendResp({ smsId: 'inbound-id' }); - const answered = await delivered; assert.ok(answered.pduObj); @@ -808,13 +772,12 @@ describe('receiving', () => { '', 'SMPP 3.4 4.6.2 leaves deliver_sm_resp\'s message_id unused', ); - assert.equal(sms.smsId, 'inbound-id', 'the id the application chose is still its own handle'); + assert.match(sms.smsId, uuidV7, 'the id the peer was not told is still the application\'s handle'); }); // 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(resolve => { session.on('sms', resolve); }); + const { incoming, peer } = await inbound(t); const text = 'the whole body, carried in the TLV'; const delivered = peer.send({ cmdName: 'deliver_sm', @@ -832,8 +795,6 @@ describe('receiving', () => { assert.equal(sms.from, '46701113311'); assert.equal(sms.to, '46709771337'); - await sms.sendResp(); - const answered = await delivered; assert.ok(answered.pduObj); @@ -841,9 +802,7 @@ 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(resolve => { session.on('sms', resolve); }); - const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806'; + const { incoming, peer } = await inbound(t); const delivered = peer.send({ cmdName: 'data_sm', params: { destination_addr: '46709771337', source_addr: '46701113311' }, @@ -855,25 +814,20 @@ describe('receiving', () => { assert.equal(sms.message, 'a message carried on the data command'); assert.equal(sms.from, '46701113311'); - await sms.sendResp({ smsId }); - const answered = await delivered; assert.ok(answered.pduObj); assert.equal(answered.pduObj.cmdName, 'data_sm_resp'); assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); // SMPP 3.4 4.7.2 gives data_sm_resp a message_id, where 4.6.2 leaves deliver_sm_resp's unused. - assert.equal(paramText(answered.pduObj.params.message_id), smsId); + assert.equal(paramText(answered.pduObj.params.message_id), sms.smsId); }); 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(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', params: { @@ -903,18 +857,12 @@ 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[] = []; + const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); - smpp.on('session', peer => { - peer.on('dlr', dlr => { reports.push(dlr); }); - peer.on('sms', sms => { - messages.push(sms); - void sms.sendResp(); - }); - }); + smpp.on('session', peer => { peer.on('dlr', dlr => { reports.push(dlr); }); }); const { session } = await connect(t, smpp, { bindType: 'transmitter' }); @@ -932,15 +880,15 @@ describe('receiving', () => { assert.ok(submitted.pduObj); assert.equal(submitted.pduObj.cmdStatus, 'ESME_ROK'); - assert.notEqual(paramText(submitted.pduObj.params.message_id), ''); assert.equal(messages[0]?.message, body); + assert.equal(paramText(submitted.pduObj.params.message_id), messages[0].smsId); assert.deepEqual(reports, [], 'an ESME submitting is never the network reporting'); }); // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it. test('refuses a data_sm segment a server has no room for with the submit code', async t => { // The two addresses and the objects are 1322 octets, so the 6-octet UDH and its text are what overrun 1330. - const smpp = await startServer(t, { maxOctets: 1330 }); + const smpp = await startServer(t, { maxOctets: 1330, onSms: () => undefined }); const { session } = await connect(t, smpp, { bindType: 'transmitter' }); assert.ok(session); @@ -965,8 +913,7 @@ describe('receiving', () => { }); test('reassembles a concatenated message whose segments arrived in message_payload', async t => { - const { peer, session } = await inbound(t); - const incoming = once(resolve => { session.on('sms', resolve); }); + const { incoming, peer } = await inbound(t); const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6); const segments = splitMessage(text, { reference: 0x3B }); @@ -994,23 +941,18 @@ describe('receiving', () => { assert.ok(sms, 'the segments join into one message wherever their bodies were carried'); assert.equal(sms.message, text); - assert.equal(sms.answeredOnArrival, true); assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); assert.deepEqual( answers.map(answer => paramText(answer.params.message_id)), answers.map(() => ''), 'SMPP 3.4 4.6.2 leaves deliver_sm_resp\'s message_id unused, segment by segment too', ); - assert.deepEqual(await sms.sendResp(), {}); }); 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(resolve => { session.on('dlr', resolve); }); let messages = 0; - - session.on('sms', () => { messages++; }); - + const { peer, session } = await inbound(t, { onSms: () => { messages++; } }); + const reported = once(resolve => { session.on('dlr', resolve); }); const delivered = peer.send({ cmdName: 'deliver_sm', params: { @@ -1083,10 +1025,9 @@ describe('receiving', () => { assert.ok((await delivered).pduObj); }); - test('reassembles a multipart inbound SMS before the sms event', async t => { + test('reassembles a multipart inbound SMS before the sms handler', async t => { const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); - const { peer, session } = await inbound(t); - const incoming = once(resolve => { session.on('sms', resolve); }); + const { incoming, peer } = await inbound(t); const segments = splitMessage(message, { reference: 42 }); assert.equal(segments.length, 2); @@ -1106,8 +1047,6 @@ describe('receiving', () => { assert.equal(sms.message, message); assert.equal(sms.pduObjs.length, 2); - assert.deepEqual(await sms.sendResp(), {}); - for (const answered of await delivered) { assert.ok(answered.pduObj); assert.equal(paramText(answered.pduObj.params.message_id), ''); @@ -1124,8 +1063,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(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 }); assert.ok(session); @@ -1153,7 +1092,6 @@ describe('receiving', () => { assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message'); assert.equal(sms.message, parts.join('')); - assert.equal(sms.answeredOnArrival, true); assert.deepEqual( answers.map(answer => paramText(answer.params.message_id)), [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`), @@ -1161,8 +1099,7 @@ 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(resolve => { session.on('sms', resolve); }); + const { incoming, peer } = await inbound(t, { server: { responseTimeout: 1000 } }); const parts = ['first ', 'second ', 'third']; for (const index of [2, 0, 1]) { @@ -1187,8 +1124,7 @@ describe('receiving', () => { }); test('reassembles a sar_* segment whose body is in message_payload', async t => { - const { peer, session } = await inbound(t, { responseTimeout: 1000 }); - const incoming = once(resolve => { session.on('sms', resolve); }); + const { incoming, peer } = await inbound(t, { server: { responseTimeout: 1000 } }); const parts = ['in the mandatory field, ', 'and in the TLV']; for (const [index, part] of parts.entries()) { @@ -1218,16 +1154,15 @@ describe('receiving', () => { assert.ok(sms, 'a segment carries its body where any other message may carry one'); assert.equal(sms.message, parts.join('')); - assert.equal(sms.answeredOnArrival, true); }); // 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, { + onSms: sms => { messages.push(sms); }, + server: { responseTimeout: 1000 }, + }); const udhText = 'the message numbered by its user data header. '.repeat(5); const udhSegments = splitMessage(udhText, { reference: 5 }); const sarParts = ['the message numbered by ', 'its optional parameters']; @@ -1271,8 +1206,7 @@ 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(resolve => { session.on('sms', resolve); }); + const { incoming, peer } = await inbound(t, { server: { responseTimeout: 1000 } }); const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4); const segments = splitMessage(message, { reference: 7 }); @@ -1301,12 +1235,14 @@ describe('receiving', () => { }); 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, { + onSms: () => { messages++; }, + server: { responseTimeout: 1000 }, + }); 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 +1286,8 @@ describe('receiving', () => { describe('delivery reports', () => { test('reaches the sender as a dlr event', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp); assert.ok(session); @@ -1362,31 +1296,26 @@ describe('delivery reports', () => { session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); }); }); - const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp({ smsId: 'dlr-id' }); - - return received; - }), + const [sms, sent] = await Promise.all([ + incoming, session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }), ]); assert.ok(sms.dlr); + assert.deepEqual(sent.smsIds, [sms.smsId]); await sms.sendDlr(); const [report, receipt] = await dlr; - assert.equal(report.smsId, 'dlr-id'); + assert.equal(report.smsId, sms.smsId); assert.equal(report.statusMsg, 'DELIVERED'); - assert.equal(receipt.tlvs.receipted_message_id?.tagValue, 'dlr-id'); + assert.equal(receipt.tlvs.receipted_message_id?.tagValue, sms.smsId); assert.equal(receipt.tlvs.message_state?.tagValue, 2); }); test('reports a failure with the spec status code', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp); assert.ok(session); @@ -1401,11 +1330,7 @@ describe('delivery reports', () => { }); const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp({ smsId: 'fail-id' }); - - return received; - }), + incoming, session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }), ]); @@ -1417,10 +1342,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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const peer = rawPeer(t, smpp.port); peer.write(bindOf(0x33)); @@ -1439,9 +1362,10 @@ describe('delivery reports', () => { }); const sms = await incoming; + const submitted = await peer.next(); - await sms.sendResp(); - await peer.next(); + assert.equal(submitted.cmdName, 'submit_sm_resp'); + assert.equal(paramText(submitted.params.message_id), sms.smsId); // A raw peer answers no deliver_sm, so this only settles once the session closes. void sms.sendDlr(); @@ -1454,10 +1378,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(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const { session } = await connect(t, smpp); assert.ok(session); @@ -1469,11 +1391,7 @@ describe('delivery reports', () => { session.on('messageDlr', () => { merged++; }); const [sms] = await Promise.all([ - incoming.then(async received => { - await received.sendResp(); - - return received; - }), + incoming, session.sendSms({ from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }), ]); @@ -1497,11 +1415,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 { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); - + const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { onSms }); const sock = net.connect({ port: smpp.port }, () => { sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex')); }); @@ -1522,11 +1437,7 @@ describe('a session captured from Kannel', () => { } }); - const sms = await incoming; - - await sms.sendResp(); - - return sms; + return incoming; } test('reassembles four segments arriving in order', async t => { @@ -1598,21 +1509,21 @@ describe('robustness', () => { }); test('keeps at most maxOutstanding requests on the wire', async t => { - const smpp = await startServer(t); let concurrent = 0; let peak = 0; + const smpp = await startServer(t, { + onRequest: async (bound, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; - smpp.on('session', session => { - session.on('sms', sms => { concurrent++; peak = Math.max(peak, concurrent); - setTimeout(() => { - concurrent--; - void sms.sendResp(); - }, 10); - }); - }); + await delay(10); + concurrent--; + await bound.sendReturn(pduObj, 'ESME_ROK', { message_id: String(pduObj.seqNr) }); + return true; + }, + }); const { session } = await connect(t, smpp, { maxOutstanding: 2 }); assert.ok(session); @@ -1653,12 +1564,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: () => undefined }); const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); assert.ok(session); @@ -1731,11 +1637,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, { onRequest: (_bound, pduObj) => isCommand(pduObj, 'submit_sm') }); const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 }); assert.ok(session); @@ -1972,13 +1875,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(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 }); @@ -1986,23 +1886,22 @@ describe('application hooks that throw or reject', () => { const sent = await session.sendSms({ from: '46701113311', - message: 'blows up the listener', + message: 'blows up the handler', to: '46709771337', }); 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.equal(sent.err, undefined, 'the message was answered before the handler ran'); + 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 }); @@ -2015,21 +1914,21 @@ describe('application hooks that throw or reject', () => { to: '46709771337', }); - assert.ok(sent.err instanceof Error); + assert.equal(sent.err, undefined); + assert.equal((await session.send({ cmdName: 'enquire_link' })).err, undefined); }); - 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(resolve => { - smpp.on('session', session => { - session.on('sessionError', resolve); - session.on('sms', async sms => { - await sms.sendResp(); + const smpp = await startServer(t, { + onSms: async () => { + await delay(1); - throw reason; - }); - }); + throw reason; + }, + }); + const failed = once(resolve => { + smpp.on('session', session => { session.on('sessionError', resolve); }); }); const { session } = await connect(t, smpp, { responseTimeout: 200 }); @@ -2043,16 +1942,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 }); @@ -2065,7 +1963,8 @@ describe('application hooks that throw or reject', () => { to: '46709771337', }); - assert.ok(sent.err instanceof Error); + assert.equal(sent.err, undefined); + assert.equal((await session.send({ cmdName: 'enquire_link' })).err, undefined); }); test('turns a rejecting session listener into a server error', async t => { @@ -2099,12 +1998,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: () => undefined }); const { session } = await connect(t, smpp, { log }); assert.ok(session); @@ -2130,12 +2024,15 @@ 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); + const smpp = await startServer(t, { + onRequest: async (bound, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; - smpp.on('session', bound => { - bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); }); - }); + await bound.sendReturn(pduObj, 'ESME_RMSGQFUL'); + return true; + }, + }); const peer = rawPeer(t, smpp.port); peer.write(bindOf(0x34)); @@ -2269,6 +2166,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,11 +2175,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); assert.ok(session); @@ -2291,7 +2186,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,16 +2227,12 @@ 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, onSms } = firstSms(); const smpp = await startServer(t, { onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; }, - }); - const incoming = once(resolve => { - smpp.on('session', bound => bound.on('sms', async sms => { - resolve(sms); - await sms.sendResp({ smsId: answeredId }); - })); + onSms, }); const { session } = await connect(t, smpp); @@ -2355,22 +2246,20 @@ describe('the server\'s onRequest hook', () => { assert.ok(answered.pduObj); assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); - assert.equal(paramText(answered.pduObj.params.message_id), answeredId); + assert.equal(paramText(answered.pduObj.params.message_id), sms.smsId); assert.equal(sms.message, 'declined by the hook'); assert.deepEqual(seen, ['submit_sm', 'enquire_link', 'unbind']); }); 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(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 +2273,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(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); @@ -2704,18 +2591,23 @@ describe('option validation', () => { test('refuses a hook that is not a function at startup, rather than once per PDU', async () => { const badAuth: ServerOptions = { port: 0 }; const badHook: ServerOptions = { port: 0 }; + const badHandler: ServerOptions = { port: 0 }; Object.assign(badAuth, { authenticate: 'yes please' }); Object.assign(badHook, { onRequest: 'refuse them all' }); + Object.assign(badHandler, { onSms: 'read them all' }); const authRefused = await server(badAuth); const hookRefused = await server(badHook); + const handlerRefused = await server(badHandler); if (authRefused.server) await authRefused.server.close(); if (hookRefused.server) await hookRefused.server.close(); + if (handlerRefused.server) await handlerRefused.server.close(); assert.match(authRefused.err?.message ?? '', /authenticate must be a function/); assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/); + assert.match(handlerRefused.err?.message ?? '', /onSms must be a function/); }); test('refuses a reassembly octet cap below 1 at startup', async () => { diff --git a/test/sms-id.test.ts b/test/sms-id.test.ts index 0240cc1..20036b3 100644 --- a/test/sms-id.test.ts +++ b/test/sms-id.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import { normaliseSmsId, parseSegmentId, respIdParams, segmentId } from '../src/sms-id.ts'; +import { normaliseSmsId, parseSegmentId, respIdParams, segmentId } from '../src/messages/sms-id.ts'; describe('normaliseSmsId()', () => { test('reads an id the length a message_id may be, and leaves a longer one alone', () => { diff --git a/test/teardown.ts b/test/teardown.ts index 70632b6..661fde1 100644 --- a/test/teardown.ts +++ b/test/teardown.ts @@ -1,4 +1,4 @@ -import type { CloseOptions } from '../src/session-options.ts'; +import type { CloseOptions } from '../src/session/session-options.ts'; import type { Server, Socket } from 'node:net'; import type { TestContext } from 'node:test'; diff --git a/test/tls.test.ts b/test/tls.test.ts index 1e311f4..a87c00d 100644 --- a/test/tls.test.ts +++ b/test/tls.test.ts @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import test, { describe } from 'node:test'; -import type { Sms } from '../src/sms.ts'; +import type { Sms } from '../src/messages/sms.ts'; import type { SmppServer } from '../src/server.ts'; +import type { SmsHandler } from '../src/session/session-options.ts'; import type { TestContext } from 'node:test'; import { Log } from '@larvit/log'; import { TLSSocket } from 'node:tls'; @@ -104,8 +105,9 @@ function createCertificate(): { cert: string; key: string } { const certificate = createCertificate(); -async function startServer(t: TestContext): Promise { +async function startServer(t: TestContext, onSms?: SmsHandler): Promise { const { err, server: smpp } = await server({ + ...(onSms ? { onSms } : {}), port: 0, tls: { cert: certificate.cert, key: certificate.key }, }); @@ -117,16 +119,20 @@ async function startServer(t: TestContext): Promise { return smpp; } -function once(register: (resolve: (value: T) => void) => void): Promise { - return new Promise(resolve => { register(resolve); }); +type Deferred = { promise: Promise; resolve: (value: T) => void }; + +/** A promise settled from the outside, for a handler that has to exist before what fires it. */ +function deferred(): Deferred { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise(settle => { resolve = settle; }); + + return { promise, resolve }; } describe('tls', () => { test('binds over a verified handshake and delivers an SMS', async t => { - const smpp = await startServer(t); - const incoming = once(resolve => { - smpp.on('session', session => session.on('sms', resolve)); - }); + const received = deferred(); + const smpp = await startServer(t, received.resolve); const { err, session } = await client({ host, port: smpp.port, @@ -145,18 +151,14 @@ describe('tls', () => { assert.equal(sock.getPeerCertificate().subject.CN, host); const [sms, sent] = await Promise.all([ - incoming.then(async received => { - await received.sendResp({ smsId: 'tls-id' }); - - return received; - }), + received.promise, session.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }), ]); assert.equal(sms.message, 'hello over tls'); assert.equal(sms.to, '46709771337'); assert.equal(sent.err, undefined); - assert.deepEqual(sent.smsIds, ['tls-id']); + assert.deepEqual(sent.smsIds, [sms.smsId]); assert.deepEqual(await session.unbind(), {}); }); @@ -191,12 +193,11 @@ describe('tls', () => { }); test('logs a handshake the server turned away', async t => { - let onWarning: ((message: string) => void) | undefined; - const warned = once(resolve => { onWarning = resolve; }); + const warned = deferred(); const log = new Log({ logLevel: 'warn', - stderr: message => onWarning?.(message), - stdout: message => onWarning?.(message), + stderr: warned.resolve, + stdout: warned.resolve, }); const { err, server: smpp } = await server({ log, @@ -213,6 +214,6 @@ describe('tls', () => { t.after(() => { sock.destroy(); }); sock.resume(); - assert.match(await warned, /client handshake failed/); + assert.match(await warned.promise, /client handshake failed/); }); }); diff --git a/test/unsendable.test.ts b/test/unsendable.test.ts index 3108910..aaaf2c7 100644 --- a/test/unsendable.test.ts +++ b/test/unsendable.test.ts @@ -1,14 +1,14 @@ import assert from 'node:assert/strict'; import test, { describe } from 'node:test'; -import type { PduObjectInput } from '../src/pdu.ts'; -import type { SendSmsDeps, SendSmsInput } from '../src/send-sms.ts'; +import type { PduObjectInput } from '../src/wire/pdu.ts'; +import type { SendSmsDeps, SendSmsInput } from '../src/messages/send-sms.ts'; import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; -import { decodeMessage } from '../src/message.ts'; -import { messageOctets } from '../src/message-body.ts'; -import { objToPdu, pduToObj } from '../src/pdu.ts'; +import { decodeMessage } from '../src/messages/message.ts'; +import { messageOctets } from '../src/messages/message-body.ts'; +import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; import { paramNumber, paramText } from '../src/defs/types.ts'; import { silentLog } from '../src/log.ts'; -import { submitSms } from '../src/send-sms.ts'; +import { submitSms } from '../src/messages/send-sms.ts'; const from = '46701113311'; const to = '46709771337'; @@ -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 GSM7 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: 'GSM7', from, message: 'Åsa naïve', to }); assert.ok(sent.err instanceof Error); - assert.match(sent.err.message, /ASCII/); + assert.match(sent.err.message, /GSM7/); 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, /GSM7/); 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..3f28cdd 100644 --- a/todo.md +++ b/todo.md @@ -25,12 +25,12 @@ 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 => { - await sms.sendResp(); +const { err: serverErr, server: smpp } = await server({ + authenticate, + onSms: async sms => { if (sms.dlr) await sms.sendDlr('DELIVERED'); - }); + }, + port, }); await smpp.close(); ``` @@ -57,12 +57,12 @@ Rules the API follows: | Merged multipart DLRs including across a reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` | | `smsIdFormat`: a peer's `submit_sm_resp` and receipt ids read into one notation before they are compared | `test/dlr.test.ts`, `test/session-extras.test.ts` | | A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `test/session-extras.test.ts` | -| A drain that also waits out the messages the application has not answered, with `sendDlr()` the one send that passes it | `test/session-extras.test.ts` | -| `OutgoingRequests`: the gate, the window, the pending map and the retry under one owner, told when a link comes up or goes down | `test/session-extras.test.ts`, `test/session.test.ts` | -| Held messages capped and expiring, so an application that answers nothing cannot grow them | `test/session-extras.test.ts` | +| A drain that also waits out the `onSms` handlers still running, with `sendDlr()` let past it | `test/session-extras.test.ts` | +| `OutgoingRequests`: one `request()` in three lanes, the window, the pending map and the retry under one owner | `test/session-extras.test.ts`, `test/session.test.ts` | +| Running handlers capped and expiring, so a handler that never settles cannot grow them | `test/session-extras.test.ts` | | A send with no link held for the next one, and one the link dropped under counted as `unanswered` | `test/session-extras.test.ts` | -| A message whose link dropped refused an answer, with its receipt still allowed out | `test/session-extras.test.ts` | -| The hold released exactly when the peer was answered: a refused `sendResp()` keeps it, a listener that rejected drops it | `test/session-extras.test.ts` | +| A receipt for a message whose link dropped still goes out on the new link | `test/session-extras.test.ts` | +| Every message answered on arrival, before its handler runs; refused where no handler takes it | `test/session-extras.test.ts` | | Every runnable README example | `test/readme.test.ts` | | Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` | | An intermediate delivery notification read as a report marked `intermediate`, as is a receipt reporting `ENROUTE` or `SCHEDULED`, and never counted into a merge | `test/dlr.test.ts`, `test/session.test.ts`, `test/session-extras.test.ts` | @@ -268,15 +268,6 @@ below. README.md or in MIGRATION.md — write it in the same change as the rule, so it is worded once. From the stability and product-owner reviews of #18. -- [ ] **Build every error from a thrown value through `errorFrom()`.** `reconnect-loop.ts` (in - `run()`'s catch and in `bringUp()`) and `client.ts`'s connect still call `String(thrown)`, - which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from - a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run. - -- [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before - the lost-link check and before the write, so a failed answer still renames the message and a - later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run. - - [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds `responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only, and Node fires a larger delay after 1 ms. From the 2026-09-28 scoring run. @@ -285,11 +276,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,50 +287,12 @@ 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 - what a reader navigates by" — and both architects reported that the map is now AGENTS.md rather - than the tree, which is that premise failing. `todo.md` already carries the opposite - instruction under Worth doing. Two records, opposite answers; one has to go. - - [ ] **Split `test/session-extras.test.ts` by the question each block answers.** 3,010 lines, 19 unrelated `describe` blocks whose names are already the file names they should be. With `session.test.ts` it is 54% of all test code and 84% the size of `src/`. "extras" names neither a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly that. `max-lines` covers `src/**` only, so nothing has stopped it growing. -- [ ] **Collapse the three objects named `defaults`.** `client.ts`, `server.ts` and - `session-options.ts` each export or hold one; `port: 2775` is written twice and the idle - timeout is derived two ways to the same 40 000, 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` - for exactly this reason and left the option's own vocabulary carrying it. Pre-1.0 the minor is - the breaking unit, so this is as cheap as it will ever be, and `todo.md` already requires the - `consts.ENCODING` names settled before the custom-encoding registry — this is the other half. - -- [ ] **Split `session-options.ts` into the things it is.** Option types and their validator, the - `SessionEvents` map, and the bind-direction rules (`bindCommands`, `bindTypeFromCommand`, - `standsInFor`, `bindCarries`) are three questions in one file, and the `defaults` table mixes - option defaults with four hard bounds that are not options. Both architects named it as where - the codebase rots first: at 34-wide it is where anything session-shaped lands. - - [ ] **Name the base-versus-segment distinction in the message id types.** `Sms.smsId` is a base, `sendSms().smsIds[]` are segment ids, `Dlr.smsId` is a segment id and `MessageDlr.smsId` is a base again — four fields, one type, `string`. The whole multipart receipt mechanism turns on @@ -371,10 +319,6 @@ below. option"). A sender with more than 1000 concurrent multipart `dlr: true` messages silently evicts the oldest at `warn`. The inherited architect hit this on the 3am walk. -- [ ] **State in README that `SmppServer.close()` reports each session's unfinished drain as - `serverError`.** Only `docs/decisions.md` says so; README's Shutdown section covers the - session's own result alone. - - [ ] **Add a ten-line SMPP glossary to the README.** Both juniors and the no-domain mid reported the same largest cost: nothing in the repo says what a PDU, `esm_class`, `data_coding`, TON/NPI or `submit_sm`-versus-`deliver_sm` are, and the inline spec citations mark a rule without @@ -406,15 +350,6 @@ below. - [ ] **Name the goal and the premise of every `docs/decisions.md` entry.** The prose sweep of #48 counted 30 of 58 entries naming no goal and 52 with no "valid while" premise. -- [ ] **Make `LinkLife` start unbound, or its decision's title true.** A link attached but not - yet bound cannot carry a request, while `phase` starts `up`, so the first link and a server - session are up before any bind. The `LinkLife` decision in `docs/decisions.md` makes the same claim - in its title, and carries the same fix. From the comprehension panel of #25. - -- [ ] **Move `checkSessionOptions()`'s doc comment to what it describes.** It explains why a count - below 1 is refused, which is `checkLimits`' job, and says nothing of the function it heads. - From the comprehension panel of #25. - - [ ] **Log why `DlrMerger.expect()` registered no merge.** Ids with no common `-` numbering return silently, the likeliest cause of a `messageDlr` that never fires and the one that leaves no trace. From the comprehension panel of #25.