18 KiB
AGENTS.md
Guidance for LLM agents working in this repository. Human-facing documentation lives in README.md; the remaining work is tracked in 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.
- Nothing throws. Every fallible function returns (or resolves to) a DTO carrying an optional
err. Nothrow, 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. - 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');
- GOOD:
- No
errorevent. Node makes an unhandlederrorevent throw, which would break rule 1. Sessions emitsessionError, servers emitserverError. - No casts, no non-null assertions.
as,as unknown asand!are all banned. Parse untyped input once through a type guard at the boundary; everything past it is typed.noUncheckedIndexedAccessis on, so every lookup into a record or buffer isT | undefineduntil 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
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
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.
docker compose run --rm node npm install
docker compose run --rm node npm test
docker compose run --rm node npm run build
- Tests are
.tsand run directly under Node's type stripping — no build step in the dev loop. - Source imports use
.tsextensions;rewriteRelativeImportExtensionsemits.jsintodist. erasableSyntaxOnlyis on, so no enums, no namespaces, no parameter properties. Useas constobjects 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.
typescriptis pinned to the 6.x line becausetypescript-eslintpeer-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
errorsand 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.mdrules. Do not write file preambles or restate what the code says. - Test data uses real randomised UUID v7 values, never
aaaa-0000placeholders. message_idvalues 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, soserver.close()hangs forever — that is a test bug, not a library one. assert.equalfromnode:assert/strictnarrows its first argument, so a following?.on the same value is flagged as unnecessary. Assert once withassert.ok(x)and use plain access after.
Decisions
-
A close arriving after our own
unbindis 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.tsexports today.Sessionis exported and publicly constructible, which is whySessionOptionsandReconnectOptionsare public too — that is correct, not a leak, and it has been raised twice. The collaboratorssession.tsdelegates 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'sbind()records thesc_interface_versionthe SMSC answered with. A peer that declared no version is recorded asundeclaredInterfaceVersion(0x00) and is sent no optional parameters — the spec reads an absentsc_interface_versionas an SMSC that supports none.undefinedis 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 bothclient()andserver(), 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 nosubmit_smand a transmitter-bound one nodeliver_sm;sendSms()andsendDlr()refuse locally, and an arriving PDU is answeredESME_RINVBNDSTS.bindAllows()is a predicate on the same footing asacceptsOptionalParams(), so the deliberately public low-levelsend()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.
SmppLoginlog.tsis what the code actually calls (debug,error,info,verbose,warn), so an application can satisfy it with an object literal and@larvit/smppships with no runtime dependencies.@larvit/logimplements it structurally and stays a devDependency, wheretest/tls.test.tspassing a realLogas the server's logger keeps that compatibility compiled. -
esm_classdecides what adeliver_smis, and the body is only read when it names nothing. Message typeMC_DELIVERY_RECEIPT(0x04) makes it a receipt whatever the body parses to, so a receipt in a formatdlrFromPdu()cannot read reachesdlrwithsmsIdundefined 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 leaveesm_classat 0, and reading that as the spec's "default message type" would lose every one of them. A non-emptyreceipted_message_idTLV marks a receipt on the same footing there, since nothing but a receipt carries one. What gets scraped is the decodedshort_messagewith any UDH stripped; a receipt this library recognises never reaches the reassembler, so an SMSC that splits one across segments gets adlrper segment rather than one merged report. Themessage_stateTLV 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 rawstatusIdand leavesstatusMsgto the body. -
A listener that rejects is routed by Node's
captureRejections, not by hand-dispatching. Both emitters construct withcaptureRejections: trueand implement[EventEmitter.captureRejectionSymbol], which lands a rejectedasynclistener onsessionErrororserverErrorbeside the synchronous guard inemit(). DispatchingrawListeners()fromemit()instead needs a cast to call them with the event's argument tuple, which hard rule 4 forbids. A rejection reason isunknownandString()throws on a null-prototype object, so both handlers normalise througherrorFrom()rather than inline — a route out of the handler would land on a bareprocess.nextTickwith nothing to catch it. -
Both emitters re-declare their listener methods to accept a promise. Maintainer's call, 2026-08-27:
EventEmittertypes every listener as void-returning, so thesession.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 returnunknown, which emits nothing, needs no cast and leaves the runtime method on the prototype. Overriding them as real methods instead cannot work: thesuper.on()call needs a cast to satisfy the conditionalListenertype. The cost is that a subclass can no longer reach those seven throughsuperor override them as methods — re-declaring them the same way is its way out.unknownrather thanvoid | 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, andteardown()runs on every path — an idle timeout and a failed rebind, not onlyclose()— 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 inteardown(), 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. -
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 imagenode:24.18.0-bookworm-slimships 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.