Files
smpp-js/AGENTS.md
T
lilleman b1c790b9a0 Retry a stream we cannot read, and cover the framer reset on attach
A framing or codec error tears the link down rather than the session, so
the loop retries it on a fresh socket with a fresh framer — which is what
a desynced stream needs. Removing that reset failed nothing before; the
reconnect test now leaves half a PDU on the dying link, and does.

`disconnected` counts failed links rather than outages, which the README
now says, and the transport wires its socket as it is built.
2026-08-31 22:27:16 +02:00

343 lines
26 KiB
Markdown

# AGENTS.md
Guidance for LLM agents working in this repository. Human-facing documentation lives in
[README.md](README.md); the remaining work is tracked in [todo.md](todo.md).
## What this is
A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 1.0.0. The branch
started from an orphan commit — no history from 0.4.0 is carried over. The 0.4.0 source is still
readable on the `master` branch of the same repository and is the reference for protocol behaviour,
not for structure or style.
The library's value is its very small API. Do not grow the public surface without being asked.
## Hard rules
These are not preferences. Breaking one is a defect.
1. **Nothing throws.** Every fallible function returns (or resolves to) a DTO carrying an optional
`err`. No `throw`, no rejected promises, no exceptions as control flow. Node APIs that throw are
wrapped at the boundary and converted into a result. Programmer errors (bad arguments) are
results too.
2. **Log messages are static strings.** Every dynamic value goes into the log metadata. Never
interpolate, never concatenate.
- GOOD: `log.debug('sendSms() - splitting message', { parts: msgs.length, to });`
- BANNED: `log.debug('sendSms() - splitting into ' + msgs.length + ' parts');`
3. **No `error` event.** Node makes an unhandled `error` event throw, which would break rule 1.
Sessions emit `sessionError`, servers emit `serverError`.
4. **No casts, no non-null assertions.** `as`, `as unknown as` and `!` are all banned. Parse untyped
input once through a type guard at the boundary; everything past it is typed. `noUncheckedIndexedAccess`
is on, so every lookup into a record or buffer is `T | undefined` until you handle it — that is the
point, not an obstacle to route around.
## Architecture
```
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: dispatch, events, and the collaborators below
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
error-from.ts errorFrom(): whatever was thrown or rejected, as an Error
expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share
incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
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
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
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<T> — the shape every fallible call returns
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 The notation a peer writes message ids in, normalised for comparison
udh.ts User data header: the concatenation fields of a long SMS
uuid.ts uuidv7() — the ids the library generates for messages
defs/
commands.ts The 33 commands, their ids and ordered parameter lists
constants.ts consts + constsById, and the SMPP version constants
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
errors.ts errors + errorsById (ESME_*)
tlvs.ts TLV definitions, tlvsById
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
```
Dependency direction is one way: `defs` knows nothing above it, `pdu` uses `defs`, `session` uses
`pdu`, and `client`/`server` use `session`. Nothing reaches back up.
**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
convention applies everywhere else, but here it corrupts every PDU.
## Toolchain
Run everything through the container; never invoke node or npm on the host.
```bash
docker compose run --rm node npm install
docker compose run --rm node npm test
docker compose run --rm node npm run build
```
- Tests are `.ts` and run directly under Node's type stripping — no build step in the dev loop.
- Source imports use `.ts` extensions; `rewriteRelativeImportExtensions` emits `.js` into `dist`.
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
objects plus union types.
- The published floor is Node 18, but the dev container runs Node 24 (type stripping needs it). CI
compiles the tests and runs them on 18/20/22/24, so the floor is verified rather than asserted.
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
TypeScript 7 once that constraint lifts.
## Defects found in 0.4.0
Confirmed by reading the 0.4.0 source. The rewrite fixes all of them; each needs a regression test
naming the behaviour, and the wire-affecting ones are cross-checked against a reference
implementation (see todo.md).
| Defect | 0.4.0 behaviour |
| --- | --- |
| LATIN1 never decodes | `decodeMsg` loops `consts.ENCODING` without breaking, so `data_coding` 0x03 lands on the alias `ISO_8859_1`, which has no decoder, and silently falls back to ASCII |
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
| `tls: true` is not TLS | Constructs a bare `new tls.Socket()` with no handshake instead of `tls.connect()` |
| Alphanumeric sender TON | `sendSms` hardcodes `source_addr_ton` to 1 (international) even for alphanumeric senders, which require TON 5 |
| Text-only DLRs refused | `deliver_sm` without both `message_state` and `receipted_message_id` TLVs is rejected with `ESME_RINVTLVSTREAM`, so Kannel-style receipts are unusable |
| Unbounded reassembly | Incomplete long-SMS groups are capped by nothing and swept only when other traffic arrives, after 24 hours |
| Dead DLR aggregation | `longSmsDlrs` is allocated to merge per-segment receipts and then never used |
| Trailing NULL truncation | `types.buffer.size()` subtracts one whenever the value's last octet is `0x00`, so the PDU is allocated one octet short while `sm_length` still reports the full length. Any UCS2 message ending in a character like U+4E00 or U+3000 goes out corrupt |
| Dormant filters | `defs.filters` is declared on commands and TLVs but never invoked anywhere. Dropped in the rewrite; SMPP time formatting is exported as `smppTime` instead |
| Unchecked reads | Wire reads index straight into the buffer, so a short or malformed PDU throws out of the codec. Reads are bounds-checked and return results now |
| Unrangechecked writes | Integer params are handed to `writeUInt8`/`writeUInt16BE` unvalidated, so an out-of-range value throws from inside Node |
| `submit_multi` missing `sm_length` | The field is commented out of the command table, so `short_message` never round-trips for that command |
| Per-parameter defaults never applied | `calcCmdLength` reads `paramType.default` (the wire type's) rather than the parameter's, so `interface_version: 0x50` on the bind commands did nothing and every bind declared version 0x00 |
| `source_telematics_id` width | Defined as a 2-octet integer; SMPP 3.4 5.3.2.8 makes it 1 octet, unlike `dest_telematics_id`, which really is 2 |
| Binary payloads decoded as text | `data_coding` 0x02, 0x04, 0x14 and 0xF4-0xF7 are 8-bit binary and land on the GSM 03.38 table, which rewrites every octet outside it. They resolve to LATIN1 now, so the payload survives as bytes |
| `ESME_RINVBCASTCHANIND` typo | Defined as `0x011`, three hex digits; the spec value is `0x0112` |
## Multipart sends and the send window
`sendSms` puts every segment of a message on the wire together instead of waiting for each response
in turn. This is not an optimisation: this library's own server holds segments until the whole
message is reassembled before it answers any of them, so sending them one-after-a-response
deadlocks. It follows that a message with more segments than `maxOutstanding` cannot be delivered to
a server that defers responses that way — real SMSCs answer each `submit_sm` immediately, so this
only bites when both ends are this library.
## GSM 7-bit is sent unpacked
Over SMPP the ESME puts one GSM character per octet in `short_message` and the SMSC packs it into
septets. The 140-octet limit applies to that packed result, not to what goes on the wire here, which
is why a concatenated segment is 153 characters plus a 6-octet UDH — 159 octets in `short_message`,
and entirely correct. Do not "fix" this to 134; that number is the packed payload size and would
truncate every long message by a fifth.
UCS2 is not packed, so there the two coincide: 67 characters = 134 octets, plus the 6-octet UDH is
exactly 140.
## Conventions
- Hard tabs. Alphabetical ordering for keys, imports and lists unless order is logic-significant.
Two deliberate exceptions: command parameters are in wire order (above), and the `errors` and TLV
tables are ordered by their numeric id so they can be diffed against the spec and gaps stay visible.
- Comments are the exception, not the default — see the root `CLAUDE.md` rules. Do not write file
preambles or restate what the code says.
- Test data uses real randomised UUID v7 values, never `aaaa-0000` placeholders.
- `message_id` values the library generates are UUID v7.
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
- Everything a test opens gets its teardown registered as it is opened, never closed on the test's
last line: an assertion that throws skips that line, and the listener it leaves behind keeps
`node --test` alive until CI's ten-minute cap. `test/teardown.ts` covers a session, a server and a
listener; anything else takes a bare `t.after`. Its close aborts rather than drains, so a test that
fails holding the send window still ends.
- `t.after` hooks run in registration order, so registering at creation tears the outermost resource
down first. A teardown that waits on a listener must destroy that listener's own connections before
it waits, or be registered after the hook that does — `net.Server.close()` does not call back until
every connection on it is gone.
- `assert.equal` from `node:assert/strict` narrows its first argument, so a following `?.` on the
same value is flagged as unnecessary. Assert once with `assert.ok(x)` and use plain access after.
## Decisions
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
reason, which is accepted — the peer sees the same TCP close either way.
- **The published surface is frozen at what `src/index.ts` exports today.** `Session` is exported and
publicly constructible, which is why `SessionOptions` and `ReconnectOptions` are public too — that
is correct, not a leak, and it has been raised twice. The collaborators `session.ts` delegates to
(`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `DlrMerger`,
`submitSms`) stay unpublished so they can be reshaped.
- **The sub-3.4 optional-parameter rule is a predicate, not a chokepoint.** `acceptsOptionalParams()`
is consulted by the library's own senders; `session.send({ tlvs })` is passed through as written,
because silently stripping a caller's explicit TLVs off a deliberately public low-level surface
would be worse than sending them. The guarantee is "what this library sends honours the rule",
never "the session cannot send optional parameters to an old peer".
- **Both ends feed `peerInterfaceVersion`, and a peer that declared nothing is pre-3.4.**
`acceptBind()` records what the ESME declared in its bind request; the client's `bind()` records
the `sc_interface_version` the SMSC answered with. A peer that declared no version is recorded as
`undeclaredInterfaceVersion` (0x00) and is sent no optional parameters — the spec reads an absent
`sc_interface_version` as an SMSC that supports none. `undefined` is left to mean one thing only:
no bind has been accepted on this session yet.
- **The library speaks SMPP 3.4 on the wire, and `defs/` keeps the 5.0 tables as a superset.**
Maintainer's call, 2026-08-26: 3.4 is what SMSCs actually run, while the wider tables let the codec
parse and build whatever a peer sends. The declared version is an option on both `client()` and
`server()`, so an implementation that needs 5.0 throughout can have it. The threshold at or above
which a peer may be sent optional parameters is fixed at 0x34 by the spec and is not the same
constant as the declared version.
- **Bind direction is enforced on the library's own senders and on everything incoming, not on
`send()`.** A receiver-bound ESME carries no `submit_sm` and a transmitter-bound one no
`deliver_sm`; `sendSms()` and `sendDlr()` refuse locally, and an arriving PDU is answered
`ESME_RINVBNDSTS`. `bindAllows()` is a predicate on the same footing as `acceptsOptionalParams()`,
so the deliberately public low-level `send()` stays a passthrough. Only those two commands are
policed, because they are the only ones the library sends and dispatches by direction.
- **The logger is a five-method contract this library declares, not a dependency.** `SmppLog` in
`log.ts` is what the code actually calls (`debug`, `error`, `info`, `verbose`, `warn`), so an
application can satisfy it with an object literal and `@larvit/smpp` ships with no runtime
dependencies. `@larvit/log` implements it structurally and stays a devDependency, where
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
- **`esm_class` decides what a `deliver_sm` is, and the body is only read when it names nothing.**
Message type `MC_DELIVERY_RECEIPT` (0x04) makes it a receipt whatever the body parses to, so a
receipt in a format `dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of
arriving as an inbound SMS. Any other named type — delivery or user acknowledgement, conversation
abort, intermediate notification — is not a receipt and its body is not scraped. A message type of
0, or one of the ten the spec reserves, keeps the scrape: SMSCs that send text-only receipts leave
`esm_class` at 0, and reading that as the spec's "default message type" would lose every one of
them. A non-empty `receipted_message_id` TLV marks a receipt on the same footing there, since
nothing but a receipt carries one. What gets scraped is the decoded `short_message` with any UDH
stripped; a receipt this library recognises never reaches the reassembler, so an SMSC that splits one across segments gets a
`dlr` per segment rather than one merged report.
The `message_state` TLV is authoritative only where it names a state in the table — SMPP reserves
0x80-0xFF for MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves
`statusMsg` to the body.
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
emitters construct with `captureRejections: true` and implement
`[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError`
or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from
`emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4
forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
on a bare `process.nextTick` with nothing to catch it.
- **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 => …)` the README documents reads as a misused promise in any strict
consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`,
which emits nothing, needs no cast and leaves the runtime method on the prototype. Overriding them
as real methods instead cannot work: the `super.on()` call needs a cast to satisfy the conditional
`Listener` type. The cost is that a subclass can no longer reach those seven through `super` or
override them as methods — re-declaring them the same way is its way out. `unknown` rather than `void | Promise<void>` because a listener may return
anything — `session.on('close', () => set.delete(session))` returns a boolean.
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
`onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
merges there loses receipts no peer has a reason to send again. They are cleared where the session
is over instead: `close()`, or a drop with no reconnect loop left to bring the link back. Inbound
segments stay in `teardown()`, because they go unanswered until the message is whole: the peer
still holds them, and answering it on a later link with the old segments' sequence numbers would
correlate with nothing. Surviving a process restart is a separate, public-surface question, and is
in todo.md.
- **A message id base is merged at most once.** A receipt carries nothing but `<base>-<n>`, so a
straggler for a message whose group is gone cannot be told from a receipt for a later message the
peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic
case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the
groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an
earlier one whose receipts are still arriving is dropped rather than left to collect the later
one's. Every segment still reaches the application as a `dlr`. The rule covers the bases the
merger opened — `expect()` ignores a lone id, so a single-part message never claims one.
- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()`
refuse further sends and wait on the send window, not the pending map — the map misses a segment
still queued behind a full window, and finishing a half-sent multipart message is the point. The
window counts slots, never outcomes, so it says when to stop waiting and nothing about what
happened: it empties on a drop too, where `teardown()` settles everything the link was carrying,
which is why `drain()` reads `closed` before it reads the count. The wait covers what this end
sent — a request the peer sent us is answered through `sendReturn()`, which never enters the
window, so a server session waits for none of its inbound work; that half is in todo.md.
`shutdownTimeout` bounds the drain alone, and `0` waits forever like every other timeout here;
`unbind()` then waits `responseTimeout` for its own response, and sends that PDU through
`request()` past both the window and the drain gate because it must go out either way. A stream
the framer or the codec cannot read takes `teardown()` instead, and `close({ signal })` on an
aborted signal and a peer's own `unbind` take `end()` — nothing on a dead link can answer, an abort
means stop now, and a peer that has declared itself finished will not answer what it still owes us,
so draining any of the three would only hold a socket open for the timeout. `SmppServer.close()`
reports each session's unfinished drain through `serverError`, because its own result says
nothing but that the listener stopped. `shutdownTimeout` stays a session option rather than a
`close()` argument: `server()` builds sessions on the caller's behalf, so the option
is the only composition point, and `close({ signal })` already covers a hard deadline.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI
and fail on every developer machine, and a committed key leaks in a public repository. Valid while
the dev image has no openssl.
- **`close` means the session is over, and a drop the loop will retry is `disconnected`.** Maintainer's
call, 2026-08-31: with reconnect on by default a `close` on every transient drop left an application
unable to tell a retry from the end, and no second one follows because `teardown()` is a no-op once
`closed`. `teardown()` picks the event by whether the reconnect loop is still live, and `end()` stops
that loop before tearing down, so every deliberate shutdown emits `close`. Without the split an
application that opens a replacement client on `close` ends up holding two binds on one account.
- **A stream this library cannot read is a dead link, not a dead session.** Maintainer's call,
2026-08-31: a framing or codec error tears the link down through `teardown()`, so the reconnect
loop retries it on a fresh socket with a fresh framer — which is what a desynced stream needs, and
the common cause. `sessionError` still carries every failure, so a peer that only ever sends
garbage is visible in the log rather than silent, and the backoff caps the retries at one per
`maxDelay`. Ending the session outright was inherited from when reconnect was opt-in, where the
distinction could not arise.
- **`disconnected` counts failed links, not outages.** A retry that opens a socket and then loses its
bind re-enters `attach()` and so emits again, which makes it deliberately not one-to-one with
`reconnected`: each emission is a link that went down, and suppressing the later ones would leave a
failed rebind with nothing but a log line. The README says so, because the pairing is what a reader
would otherwise assume.
- **`session.sock` is a getter over `PduTransport`, and stays public.** Maintainer's call, 2026-08-31:
`session.ts` had reached its line cap, so the socket-to-PDU seam todo.md named was opened —
`PduTransport` owns the socket, the framer and the parse, and hands the session raw bytes, framed
PDUs, parsed ones and an unreadable stream. Reading `session.sock` is unchanged; assigning it no
longer compiles, which never rewired the handlers and so never worked. The transport stays
unpublished like the other collaborators.
- **A client re-binds after a drop unless it is told not to.** Maintainer's call, 2026-08-31:
surviving a dropped link is most of what the session layer is for, and behind an opt-in an
application that never read the options table got none of it. `reconnect` takes
`{ minDelay, maxDelay }` to retune the backoff and `false` to turn it off, so absent means on and
there is one spelling for each. Only `client()` reconnects — a `server()` session is a connection
the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d, so a
process with nothing else left to do still exits between attempts.
- **The notation a peer writes message ids in is named per place, and normalisation never reaches
inside a `<base>-<n>` id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's
`id:` in decimal, so one transform over both sides cannot make them equal — `smsIdFormat` names
`receipt` and `submitResp` separately and reads both into a plain decimal value before `smsIds`
and `dlr.smsId` are compared. `submitResp` covers the `receipted_message_id` TLV too, which SMPP
3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one notation for whichever id
a receipt yields would break the peer that sends both, whose TLV correlated before the option was
set. Omitting a place is what leaving it alone means, so there is no
`raw` notation, and a caller-supplied formatter is refused because it would make the promise that
those two are comparable unverifiable — `onRequest` and the PDU on the `dlr` event are the escape
hatches, and the `onReceipt` hook in todo.md is the seam if one is wanted. An id no notation reads
is left exactly as it arrived, which is what keeps `DlrMerger` working: a `<base>-<n>` id parses
as no number and so reaches `expect()` and `collect()` unchanged. Normalising the base instead
would break that pair. The option is on `client()` only — a `server()` session generates its own
ids and writes its own receipts, so both places are already one notation.