Files
smpp-js/AGENTS.md
T

85 KiB
Raw Permalink Blame History

AGENTS.md

Guidance for LLM agents working in this repository. What each file in it is for is under Documentation.

What this is

A ground-up TypeScript rewrite of larvitsmpp 0.4.0, published as @larvit/smpp 0.5.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 v0.4.0 branch of the same repository and is the reference for protocol behaviour, not for structure or style.

Goals

In priority order, and the order is the point: where two of them pull against each other, the earlier one wins. They do not override the hard rules below.

  1. Correct on the wire. SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one; the defect table below is what the alternative costs.
  2. Never give the application a wrong answer about what happened. An outcome we cannot determine is reported as undetermined rather than guessed; a report the peer marked as not final settles nothing, so nothing the library concludes may rest on one; a request the peer may already have taken is never re-sent on the library's own initiative; work the peer has no reason to send again is not dropped.
  3. Strict in what we send, generous in what we read. The library's own senders follow 3.4, and the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC sends, keep the traffic.
  4. A peer an operator never has to complain about. No bind flooding, nothing a bind direction forbids, no optional parameters to a peer that declared none, nothing held without a bound.
  5. The session layer is in here, and its defaults are what most applications should run. Keepalive, reconnect, the send window, reassembly and receipt correlation. What the network says about a message the application sent reaches it as a report rather than as an inbound message, and says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a default or opts out of it; an option does not switch on the thing the caller obviously wanted.
  6. A small, stable public surface over reshapeable internals. Only what src/index.ts exports is published. A new option has to beat "the application can do this itself", and has to keep a promise this library can verify. The low-level surface is a passthrough: policy binds what the library composes, never what the caller wrote.
  7. Nothing that needs state wider than one session. No throughput throttling, no persistence across a restart, no coordination between processes — and no seam handing the application state to persist for one of those either, which commits to the same scope through the back door and publishes an internal shape to do it. This is the scope floor, and it is why an otherwise reasonable feature is declined without a fresh argument each time.
  8. It builds, tests and runs the same everywhere. Container-only toolchain, no runtime dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed by the suite.

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, wherever the types admit one: a function whose argument types are a closed set is guarded by the compiler and stays total, which is why the encoding helpers return plainly, and the check belongs at whichever boundary the argument arrives untyped at.
  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: 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
	error-from.ts        An untyped value as error material: errorFrom() an Error, namedValue() a name
	expiring-groups.ts   ExpiringGroups: the capped, expiring store both of those share
	held-messages.ts     HeldMessages: capped, expiring messages the application has not answered
	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-gate.ts         LinkGate: where a request with no link to go out on 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 gate, 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<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            Message ids: the peer's notation, the <base>-<n> a segment gets, which response carries one
	udh.ts               User data header: its length, the concatenation fields of a long SMS and their reference
	unanswered-error.ts  UnansweredError: it went out and no answer came back
	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, the input shape, and writing a TLV stream
		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 .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, every LTS above it, and current, 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

Every row names what 0.4.0's own code did, so it is not rebuilt here. MIGRATION.md names what changed for a consumer, and is the only place that does. Confirmed by reading the 0.4.0 source; each row has a regression test naming the behaviour.

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)
GSM 03.38 declared as IA5 sendSms resolves its encoding through consts.ENCODING, so a GSM body goes out under data_coding 0x01 — SMPP 3.4 5.2.19's IA5 (CCITT T.50), where $ and @ are STX and NUL
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
sar_* segmentation unread session.js reassembles on the UDH alone, so a message segmented with sar_msg_ref_num/sar_total_segments/sar_segment_seqnum — SMPP 3.4's other spelling, and Jasmin's documented default — reaches the application one fragment per segment
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
Unchecked reads Wire reads index straight into the buffer, so a short or malformed PDU throws out of the codec
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
Binary TLVs round-trip corrupt pduToObj turns a Buffer TLV value into a hex string (utils.js:307), and objToPdu writes that string back as its own ASCII, so message_payload, network_error_code, callback_num and the rest are destroyed by any round trip
ESME_RINVBCASTCHANIND typo Defined as 0x011, three hex digits; the spec value is 0x0112
Every response carries a message id session.js builds params = {'message_id': …} for every response it sends, deliver_sm_resp included; SMPP 3.4 4.6.2 makes that field unused and NULL, and Jasmin closes the connection on one

Multipart sends

sendSms puts every segment of a message on the wire together instead of waiting for each response in turn, so a long message costs one round trip rather than one per segment. Nothing on the receiving side forces the order either way: this library answers each inbound segment as it arrives, so a peer that dispatches one request at a time is never left waiting on us.

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 GSM segment is 153 characters plus a 6-octet UDH — 159 octets in short_message, and entirely correct. Do not "fix" that to 134; that number is the packed payload size and would truncate every long GSM message by a fifth.

GSM 7-bit is the only alphabet it applies to. What each of the three is budgeted, and why, is a decision under The wire.

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.
  • Fixtures that encode the wire are shared so no two files can drift on it: test/raw-pdus.ts builds the octets a test writes straight to a socket, the PDUs objToPdu() refuses to build included. So is the peer that answers on its own: test/dummy-smsc.ts is the one auto-answering SMSC, because two copies drift in what they answer rather than in what a test asserts, and one that quietly stops answering enquire_link fails the file that copied it for a reason nothing in that file names. Reach for it where the peer's answers are not what the test is about; where they are, smscPeer() in test/session.test.ts answers the bind and hands every other PDU to the test to answer, and stays there because that is a different peer rather than a second copy of this one. The waiting helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and nothing else, and a helper that only names the parameters of one objToPdu() call is on that same footing — it encodes no wire fact objToPdu() does not already own. So is a stub standing in for a collaborator the type system already keeps in step: recordingDeps() in messaging-mode.test.ts, message-class.test.ts and unsendable.test.ts is one SendSmsDeps.send that answers nothing, and a field added to that type fails to compile in every copy at once.
  • 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.

Documentation

Each file answers one question, and a fact belongs to the file whose question it answers:

  • README.md — what you can rely on. Observable behaviour, for someone using the package. It carries a reason only where the reason changes how you would call the thing.
  • MIGRATION.md — what a 0.4.0 consumer has to change. Renamed and removed surface, and the behaviour that changed on the wire.
  • AGENTS.md — what may not change, and why. Goals, hard rules, architecture, conventions, and the decisions the goals do not already settle. It does not restate behaviour README states.
  • todo.md is a working file that sets its own rules; nothing here governs it.

A sentence living in two of them is a defect: delete the copy in the file whose question it does not answer. The toolchain commands are the one deliberate exception — README's copy serves a contributor who never opens this file, and this file's copy carries the constraint that nothing runs on the host.

Write a decision down only when it cannot be put better as a goal. A goal decides every case that follows from it; a decision record decides one. So reach for the goal list first — sharpen a goal, add one, or move one up the order — and write a decision only for what is left over: a choice a competent change would otherwise re-open, that no goal implies. Give the claim, the constraint that settled it and the alternative rejected, and nothing the code or README already says. Where a compiler or a test already forbids the other way, it is not a decision, it is a test name. Delete one once it no longer constrains anything; this is not a changelog.

Decisions

Grouped by what each one constrains.

The public surface

  • Session is publicly constructible, which is what makes SessionOptions and ReconnectOptions public too. Raised twice as a leak; it is not one. The collaborators session.ts delegates to (Reassembler, PendingRequests, SendWindow, ReconnectLoop, LinkTimers, LinkGate, DlrMerger, PduTransport, submitSms) stay unpublished so they can be reshaped.

  • acceptsOptionalParams() and bindAllows() are predicates, not chokepoints. The library's own senders consult them; session.send({ tlvs }) is passed through as written, because silently stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than sending them. Only submit_sm, deliver_sm and data_sm are policed by bind direction — the three the library dispatches by it, of which it sends the first two.

  • session.sock is a getter over PduTransport. Reading it is unchanged; assigning it no longer compiles, which never rewired the handlers and so never worked.

  • Both emitters re-declare their listener methods to accept a promise. Maintainer's call, 2026-08-27: EventEmitter types every listener as void-returning, so the session.on('sms', async sms => …) README documents reads as a misused promise in any strict consumer. declare on: … and its six siblings re-type the inherited methods to return unknown, which emits nothing and needs no cast; overriding them as real methods cannot work, because the super.on() call needs one. The cost is that a subclass can no longer reach those seven through super — re-declaring them the same way is its way out. unknown rather than void | Promise<void> because a listener may return anything: session.on('close', () => set.delete(session)) returns a boolean. This also settles what the drain can wait on: a listener's own promise would be the better completion signal, and reaching it needs listeners(), which cannot be re-declared the same way — Node types it invariantly enough that widening void to unknown is TS2416. Re-probed 2026-09-01; sendResp() stays the signal.

  • PduRefusedError is exported, and sessionError names it in the event's type. Maintainer's call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the session's own failure, and instanceof is the only way to separate them that hard rule 4 allows — without the class as a value an application is left string-matching err.message. Goal 6 is paid by exporting the discriminant and the struct it carries and nothing else: PduHeader is named because an application that logs or forwards a header wants a name for it, PduRefusalReason is not because reason is compared against string literals, and an accessor (PduRefusedError['header']) names either one where a signature wants it. The payload union enforces nothing — a subclass narrows out of Error either way — and is there so the event's own type names what to narrow to, which is also what makes it a half-truth if a second Error subclass ever reaches this event without joining it. Rejected: a SessionError alias for that union, a third name for a type that is structurally Error. Rejected: a separate pduRefused event, which splits the failure channel so an application that wants every failure listens twice and an existing listener silently stops seeing refusals. Rejected: coalescing or rate-limiting them, which re-opens the standing decision that sessionError carries every failure, never coalesced or suppressed — the filtering belongs where the application is, since only it knows which peer is routinely sloppy. Rejected: an error code on a plain Error, which reads back off an unknown property only through a cast and types nothing it carries. Accepted: a second copy of the package installed alongside this one defeats instanceof, where err.name still reads PduRefusedError.

  • bitCount(), encodeMessage() and splitMessage() keep their total signatures, because EncodingName is what keeps an alphabet with no codec away from them. Maintainer's call, 2026-09-09, from the architecture review of #95: all three index encodings by name and would throw on one it has no codec for, which hard rule 1 forbids. That PR left no such name to pass — encodings is a Record<EncodingName, Encoding>, so every member of the union has a codec and one added without a codec, or without a segment budget, fails to compile in four places. What was missing is the door for a caller holding a name at runtime: Object.hasOwn(encodings, x) is the only test the published surface offered and it narrows nothing, so isEncodingName() is exported beside isCommandName() and isErrorName(), which serve their own tables that way. Rejected: a Result signature on all three, which costs every typed consumer a narrow forever — goal 6, and the tag is the last cheap chance to spend it — to guard a state the compiler refuses. Where the domain really is open the check is already there: sendSms() takes its options as unknown and refuses encoding by name, which is what a caller without types gets. smppTime.encode() is where that reasoning lands the other way and is recorded under The wire: Date | number | string is not a closed set, so it is a Result.

  • A segment the SMSC took and named no id for is undefined in smsIds, not an empty string. Maintainer's call, 2026-09-12: paramText() resolves an absent message_id and one a peer wrote empty to the same '', which string[] then presented as an id — taking smsIds[0], or keying a correlation table by the array, compiled and then misbehaved, and one message's empty entry collides with another's. Telesign names an id for the first segment of a concatenated submit only, so it is a documented operator's shape rather than a hypothesis. Nothing else moves: parseSegmentId('') matched nothing, so DlrMerger already abandoned such a send and undefined reaches that same refusal. expect() takes the wider type rather than a filtered string[] because the arity is what idNumbering() refuses on: filtering ['a-1', undefined, 'a-3'] leaves a numbering that spells out a whole message, and merges one that was never whole. dlrFromPdu() reads an id through nonEmptyText(), so no receipt could ever have matched an empty entry — and that reading stays separate from this one rather than sharing a helper, since it must leave a Buffer-valued receipted_message_id unresolved for messageType() to read the PDU as unmarked. What settles it here is the resolved text rather than the parameter, because writeParams() substitutes the field's own default: a peer that omits message_id and one that writes it empty build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping '' and documenting it, which leaves the published type promising what the value does not keep — goal 2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which breaks the positional correspondence with pduObjs that README promises and loses which segment a PDU belongs to. Rejected: { id?: string; pduObj: PduObject }[], which makes that positional promise structural where today the compiler cannot check it; deferred to the next breaking release, the first place two documented fields may become one. Accepted: every consumer reading smsIds narrows, including the majority whose SMSC names every id; indexing narrows too, except for the consumer who sets noUncheckedIndexedAccess, which typed smsIds[0] as string | undefined already.

The wire

  • The declared interface version is an option on both client() and server(), and is not the optional-parameter threshold. That threshold is fixed at 0x34 by the spec, so an implementation that must declare 5.0 throughout can, without moving it.

  • A peer that declared no version is pre-3.4, and undefined means no bind yet. acceptBind() records what the ESME declared and the client's bind() records the sc_interface_version the SMSC answered with; a peer that declared nothing is recorded as undeclaredInterfaceVersion (0x00) and sent no optional parameters, which is how the spec reads an absent sc_interface_version.

  • esm_class decides what a deliver_sm is, and the body is read only when it names nothing. The two types the MC writes about a message we submitted — MC_DELIVERY_RECEIPT (0x04) and INTERMEDIATE_DELIVERY (0x20) — are reports whatever the body parses to, so one in a format dlrFromPdu() cannot read reaches dlr with smsId undefined instead of arriving as an inbound SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement is the handset's word about a message, not the network's. A message type of 0 or one of the ten reserved keeps the scrape, and a non-empty receipted_message_id TLV marks a report on the same footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one across segments gets a dlr per segment rather than one merged report. The message_state TLV is authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for MC-vendor-specific values, so an unnameable one keeps its raw statusId and leaves statusMsg to the body.

  • A body is read from message_payload where short_message carries none, and short_message wins where a peer filled both. Maintainer's call, 2026-09-06, from the Jasmin interoperability phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot carry, several SMSCs use it, and Jasmin relays one faithfully — reading short_message alone handed the application an empty message (interop-tests/findings/03-jasmin.md). messageOctets() is the single answer to where a body is, so the message path, the reassembler and dlrFromPdu() cannot disagree about it, and esm_class still says whether that body starts with a UDH wherever it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the spec's own instruction to leave sm_length zero, and taking the mandatory field there keeps the rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both, which discards a message that is almost certainly present twice over, where goal 3 keeps the traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like any other segment.

  • A segment's concatenation is read from its UDH, or from the sar_* TLVs where it declares none, and each spelling groups in a reference space of its own. Maintainer's call, 2026-09-06, from the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make sar_msg_ref_num/sar_total_segments/sar_segment_seqnum the other way to say what a UDH says, Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed the application one sms per fragment (interop-tests/findings/05-java-clients.md). concatOf() is the single answer to how a PDU says it is a segment, as messageOctets() is to where a body is, and both are exported for the same reason: an application on the low-level surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the reference, so the key is two tokens the reassembler joins and interprets neither of, and so the refusal can name the field the peer got wrong — ESME_RINVESMCLASS for a UDH, ESME_RINVTLVVAL for the TLVs, whose segment's esm_class is 0x00 and correct. Keying them together instead would assemble two of a peer's messages into one, since a UDH reference is 8 bits and sar_msg_ref_num is 16 and neither counts the other's messages; the two UDH widths share a space because they are one sender's counter in one layer, where a sar_* reference is another layer's. A UDH that names the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps the change additive for every message that reassembled before, and leaves the library nothing to guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the references are not comparable at all, and where the parts are, the UDH is still what the message is assembled by, so the report would name a failure the application cannot act on. Accepted: a peer that switches spelling mid-message now has two groups that expire rather than fragments that arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: sendSms() goes on writing a UDH with an 8-bit reference, where a send-side sar_* would be a second spelling of one message whose only difference is which peers accept it.

  • sendSms() takes the messaging mode by name, and it is the only part of esm_class a caller writes. Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and Kaleyra both document esm_class 0x43 for one, and a caller facing either had to hand-build every segment through send() — giving up the split, the per-segment ids, the send window and the receipt merge, which is what goal 6 means by beating "the application can do this itself". The four modes of SMPP 3.4 5.2.12 are a MESSAGING_MODE constant group and the option takes one of their names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH indicator a segment carrying a header needs cannot be cleared by anything the option can express. It takes three of those four: 2.10.3 carries transaction mode on data_sm alone, and none goes out of here, so FORWARD stays in the group that mirrors the spec table and sendSms() refuses it by that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 6 will not let it make. DATAGRAM with dlr: true is refused on the same footing: 2.10.2 defines the report away, so arming DlrMerger for one is goal 2's wrong answer, where the mode alone and a report under any other mode both go out untouched. Those three names left ESM_CLASS, where they had constsById.ESM_CLASS read 0x03 as a whole esm_class. Rejected: a raw esmClass number, which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected: taking a number beside a name, two spellings of one goal — which is why a value naming no mode is refused, by name, before a segment goes out. Rejected: a session-level default with a per-send override; an operator's requirement is a property of the link, but the library can verify nothing the caller's own options object does not, and shipping both buys a precedence rule to document and test for that. SMSC_DEFAULT is named so pinning the default deliberately is sayable.

  • An inbound data_sm stands in for whichever of submit_sm and deliver_sm its direction makes it, and none goes out. Maintainer's call, 2026-09-06, from the Jasmin interoperability phase: SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in message_payload, and Jasmin's [dlr-thrower] dlr_pdu = data_sm throws real receipts on it, which ESME_RINVCMDID dropped with nothing reported to the application at all. Every command but this one names its own direction, which is why the bind gate and the dispatch never had to be told which end of the link they are on; linkEnd is that fact, and it decides both. At the ESME end an inbound one is a delivery, so esm_class classifies it as it classifies a deliver_sm; at the SMSC end it is a submission and is read as one, because a report about a message this end never sent is goal 2's wrong answer whatever esm_class a peer wrote on it. A concatenated one is answered segment by segment either way, and 4.7.2 gives data_sm_resp a message_id where 4.6.2 leaves deliver_sm_resp's unused, so the answer carries one. linkEnd is a field beside boundAs rather than a SessionOptions entry, so the code that knows which end this is writes it and nothing else can contradict what the session then binds as. Rejected: grouping the command with deliver_sm in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with submit_sm, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one — send() reaches the command raw, and an option choosing which command a message goes out on would be a second spelling of sendSms() whose only difference is which peers accept it.

  • A receipt's body is read as octets, and its own data_coding never says how. Maintainer's call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's data_coding onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss against the many peers that send no TLVs to fall back on. dlrFromPdu() reads PduObject.shortMessageOctets through Latin-1, the one codec that maps every octet to a character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings because a message needs the text and a receipt needs the octets. Rejected: honouring data_coding where the octets yield no field, which reads one body two ways for the sake of a peer writing a UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here, which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing but data_coding can say how a message was written.

  • A message class is read where GSM 03.38 puts it, flash is class 0 alone, and a flash message with no alphabet to carry it is refused. Maintainer's call, 2026-09-09, closing the last target of the interoperability plan: sms.flash was (data_coding & 0xF0) === 0x10, which called the ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM 03.38, and the one SMPPSim demonstrated (interop-tests/findings/02-smppsim.md, C17). messageClassOf() is the single answer to whether a data_coding carries a class and which, as concatOf() is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0, carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and encodingByDataCoding() reads the alphabet off that same test rather than repeating the group masks beside it. It is exported for the reason concatOf() is — an application that needs a class other than 0 would otherwise rewrite the read this fixed. Rejected: a messageClass field on the sms event, which pays goal 6 for three classes nothing here acts on, where the boolean the application already had covers the one it does. Compressed text is out of scope and stays out — nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38 coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins (0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is spelling 03.38 at all. Send-side: flash is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while encoding: 'LATIN1' beside it is refused before a segment goes out, the way a messaging mode this library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets, which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to the handset that would display it; rejected: promoting it to UCS2, which overrides the one option the caller wrote in order to override a choice. Rejected with them: encoding: 'FLASH', which named data_coding 0x10 among the alphabets and so reached the class through the option that chooses a charset — a second spelling of flash: true that also flattened every non-GSM character to a space on the way. It leaves EncodingName, which is now exactly the three codecs detect() and encodingByDataCoding() return, and encoding is checked by name like messagingMode so a caller without types gets a refusal rather than a throw out of the codec table. consts.ENCODING keeps its FLASH entry: the low-level surface reaches raw constants, and nothing reads that group as an alphabet any more. Accepted: flash is now false for data_coding 0x11 to 0x13, which no peer means as immediate display.

  • A report is final unless its esm_class or its state says otherwise, and only ENROUTE and SCHEDULED say otherwise. SMPP 3.4 Appendix B lists every other receipt state as final, UNKNOWN and ACCEPTED included, so a peer writing ACCEPTD for a carrier-accepted step is taken at its word. Rejected: reading UNKNOWN as non-final, which leaves a peer whose receipt body this library cannot read with no messageDlr at all — goal 2 wants that reported as undetermined, not withheld. Both spellings resolve into Dlr.intermediate at the boundary rather than being read a second time in DlrMerger, so the library cannot answer the application one way and conclude the other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying stat:ENROUTE is common — so the state test is what the marker test cannot replace. message_state 0 is 5.0's SCHEDULED and undefined in 3.4; a peer that writes it is read as transient rather than as saying nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an absent one cannot land there.

  • A stat: an operator spells outside Appendix B is read as the state it names, and the two researched ones are FAILED and CM.com's DELIVERD. Maintainer's call, 2026-09-08, from the operator-fixture phase: Kaleyra and Route Mobile both document FAILED in that field as a terminal delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints DELIVERD — eight characters — beside six correct ones. Both were left at statusMsg: UNKNOWN — the same answer a receipt really saying stat:UNKNOWN gets, so an application could not tell an operator's "it failed" from its "I do not know", nor a delivered message from one whose state could not be read; and DlrMerger ranks UNKNOWN below EXPIRED, reporting a multipart send carrying a failed segment as expired. They join receiptStates alone: receiptCodes goes on writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a FAILED member of MESSAGE_STATE, which is 3.4's own numbered table — the code has no number there, so one would have to be invented, and every consumer's switch would grow a case no message_state TLV can carry. Rejected: leaving it UNKNOWN and sending the application to dlr.receipt.stat for the state, which reports a terminal failure as undetermined and leaves the merge ranking it below EXPIRED. Rejected: reading the numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of their own rather than the seven characters stat: holds. Accepted: all three of those operators document FAILED and UNDELIV as separate codes, and both now resolve to UNDELIVERABLE — an application that must tell them apart reads dlr.receipt.stat, which carries what the SMSC wrote. Accepted: an unmarked deliver_sm whose body says one of them now reaches the application as a report where it used to arrive as an inbound message, which is what every code already in the table does. What decides a spelling is whether the corpus in test/operator-receipts.test.ts can cite the page it is printed on and no other code could be meant, which is why DELIVERD is read and a spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely contain a typo saves an application everything where they do not.

  • A transient state goes out as an intermediate delivery notification (0x20), every other state as a delivery receipt (0x04). Appendix B makes a receipt's stat the message's final status, so 0x04 over ENROUTE emits the two disagreeing spellings of finality the reading side above has to reconcile, and goal 3 has our own senders write the marker 3.4 defines. sendDlr() takes the list from transientStates in dlr.ts, the same one the reader uses, so the two cannot drift. Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost accepted here is that such a peer stops recognising a transient report as a report at all and hands its application receipt text as an inbound message, where under 0x04 it would have read the state from stat: and been right. A transient state also carries err:000, since a message still on its way has not failed.

  • A refused PDU is answered from its header, and any 32-bit sequence_number is echoed as it arrived. Maintainer's call, 2026-09-05 via the interop plan. The header of a framed PDU always parses, so it carries the answer SMPP 3.4 4.3 asks for, with the status 3.4 names for the part that would not parse. Rejected: nacking a refused response, whose sequence number is one of ours — the generic_nack would land in the peer's own numbering and nack a request of the peer's we never saw, so a refused response is written back nothing and settles the request it names instead. An unknown command id with the response bit set takes that branch too: a peer echoing a sequence number of ours is answering something, and settling it reaches the undetermined outcome responseTimeout would have reached anyway, sooner. Rejected: clamping a sequence number outside 4.7.1's 0x000000010x7FFFFFFF into range before answering, which correlates with nothing at the peer — stacks write the field as a plain uint32 (ukarim/smscsim signs every unprompted deliver_sm with a raw rand.Int()), so goal 3 keeps that traffic and PendingRequests.nextSeqNr(), the only thing that invents one, is what holds our own sends inside the spec.

  • The optional parameters run to command_length exactly, and the only slack tolerated is one NULL octet where a peer padded short_message. Maintainer's call, 2026-09-06, from the Java-client interoperability phase: accepting any parse that merely did not error answered ESME_ROK to a deliver_sm whose three trailing octets were never read, dropping the receipted_message_id that makes a receipt a receipt (interop-tests/findings/05-java-clients.md). Goal 2 settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region that does not end on command_length — the padded read included — is refused with the tlvs reason and ESME_RINVTLVSTREAM a truncated TLV value already gets. What the rule costs is paid once, in readCstring(): a trailing C-Octet String a peer left out entirely consumes no octet, where reporting the terminator it never sent puts every later offset past the declared end and refuses a bind, and every bodyless response, that used to parse. That composes, so a run of them at the tail all read empty — outbind is the only command with two, and an absent field and an empty one say the same thing, so goal 2 is not at stake even there. Rejected: keeping the tolerance for the one to three trailing octets too few to hold a TLV header, which no researched peer sends and which cannot be told apart from the truncated tail this fixes. Rejected: refusing it as body/ESME_RINVCMDLEN, which names the mandatory fields — the part the peer got right.

  • smsIdFormat names a notation per place, and normalisation never reaches inside a <base>-<n> id. An SMSC may answer submit_sm_resp in hex and write the receipt's id: in decimal, so one transform over both sides cannot make them equal. submitResp covers the receipted_message_id TLV too, which SMPP 3.4 5.3.2.26 defines as the id the submit_sm_resp carried: naming one notation for whichever id a receipt yields would break the peer that sends both. Omitting a place is what leaving it alone means, so there is no raw notation, and a caller-supplied formatter is refused because it would make the promise that the two ids are comparable unverifiable — onRequest and the PDU on the dlr event are the escape hatches. A <base>-<n> id parses as no number and so reaches expect() and collect() unchanged, which is what keeps DlrMerger working; normalising the base instead would break that pair. The option is on client() only, since a server() session writes both ids itself.

  • A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them and 134 octets of anything it does not. Maintainer's call, 2026-09-09, from the architecture review of #95: segmentUnits handed 153 to everything but UCS2, so a long encoding: 'LATIN1' message went out as segments of 153 octets plus a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153 that is the odd number rather than the other way round. Record<EncodingName, number> is what makes a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake the unpacked-alphabet section above exists to stop. Accepted: a Latin-1 message past the 140 characters one SMS holds now costs more segments than it did, and smsIds is that much longer.

  • An alphabet the caller named has to carry the message, and a time the format cannot express is refused, both before a segment goes out. Maintainer's call, 2026-09-09, from the architecture and stability reviews of #96: encoding: 'LATIN1' on あいう put 42 44 46"BDF" — on the wire and returned success, encoding: 'ASCII' flattened every character outside 03.38 to a space, and validityPeriod: new Date('nope') wrote NaNNaNNaNNaNNaNNaNNaN00+ into the PDU. Goal 2 owns all three: bytes that do not say what the caller asked, reported as sent. unencodable() is the single answer to whether an alphabet can carry a message, as messageClassOf() is to whether a data_coding carries a class, and it asks the codec — decode(encode(c)) === c per code point — rather than restating the tables beside it, so the guard cannot drift from what the encoder writes for any one character, and a fourth alphabet answers by having a codec at all. It is exported for the reason concatOf() is: a caller composing a submit_sm through send() and encodeMessage() would otherwise rewrite the read this fixed. match() cannot be that answer — it doubles as the auto-selection policy detect() reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit binary body Latin-1 is kept for. The guard is on the named branch alone, so an unspecified send is untouched: every alphabet detect() returns carries every character it was picked for, over the whole code point range. smppTime.encode() returns a Result, where the three encoding helpers stayed total: that argument was that EncodingName is a closed set the compiler guards, and Date | number | string is not — an invalid Date and NaN inhabit it, which hard rule 1 makes a result "wherever the types admit one", and decode() has been fallible for the same reason since it was written. Rejected: transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice — the same reason a flash Latin-1 message is refused rather than promoted, and an operator that accepts only data_coding 0x03 would be handed something it never agreed to take. Rejected: guarding sendSms() alone and leaving smppTime.encode() writing NaNs, which leaves this library's own published helper composing the garbage the guard exists to stop. Rejected: a holds() member beside match() on Encoding, a second per-alphabet table to keep in step with the codec. Rejected: validating the string spelling of a time, which is a stamp the caller formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the field 1 or 17 octets. Accepted: a second count past 99d 23:59:59 is refused rather than clamped to it, a negative one and Infinity with it — clamping 86400 * 365 reported success for a year and put 99 days on the wire, the wrong answer about what happened that the rest of this bullet exists to remove. The ceiling is this encoder's rather than the format's: 3.4's YYMMDDhhmmss000R carries years and months, which decode() reads back, and no fixed number of seconds is either one, so spelling a second count in days and below is where the guess would go — which is why the too-long refusal names the Date that reaches every instant the absolute form holds, and the negative one names nothing, there being no period to reach. Rejected: documenting the clamp, which leaves the caller told a true thing and still sent the wrong period. Accepted: GSM's 0x1B is an extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is the one input a per-character reading passes and the encoder then writes as the extended character — the only composition in any of the three codecs, and not a character a message is written in.

  • A string body is written in the alphabet its own data_coding names, and one that alphabet cannot carry is refused by the codec — message_payload on the same terms as short_message. Maintainer's call, 2026-09-09, from the architecture review of #97: objToPdu() took the codec off the caller's own data_coding and encoded with it whatever the text was, so data_coding 3 beside あいう returned 42 44 46"BDF" — reported as built, while a string message_payload was cut to its low octets whatever data_coding said. Goal 2 owns it, as it owns the sendSms() guard above. The line falls at the string: a Buffer is octets the caller already chose and goes out as given under any data_coding, which is what keeps goal 6's escape hatch open — the raw UDH, 8-bit binary and deliberately malformed bodies interop-tests/ builds are all still buildable — and a string with no data_coding is untouched, detection carrying every character it was picked for. The guard is unencodable() again rather than a second reading, and unencodableText() is the character, its code point and its index said once for both refusals — unexported where unencodable() is published, since wording { char, index } into a sentence rewrites no read a caller would get wrong, where asking the codec is, and publishing it would freeze this library's error prose as API for an application whose own refusal should read like itself. Goal 6, from the architecture review of #99, 2026-09-09. It is reached through encodeBody() in message.ts, which is where the data_coding-to-text pair already lives: encodeBody(text, dataCoding) is decodeMessage(buffer, dataCoding)'s mirror and resolves the alphabet through the same encodingByDataCoding(). send() and sendReturn() inherit it, since both build through buildPdu(); sendSms() does not, and keeps its own guard, because splitMessage() hands the codec a Buffer with nothing left to refuse and the index a segment could name is not the one in the message. The TLV is encoded rather than merely checked because data_coding names the alphabet of the body wherever it is carried — that is how messageOctets() and decodeMessage() read one back, and a data_sm has nowhere else to put one — so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would close half of it. short_message settles the data_coding wherever it carries octets at all, the order messageOctets() reads the two in, so the alphabet a PDU declares is the one its body will be read under — and a short_message on a command whose table declares none is ignored here as writeParams() ignores it, so an empty one, an absent one and one the wire cannot carry are the same input rather than three. A data_coding on a command that declares no such field is honoured the other way round, since it is replace_sm's only way to name the alphabet its octets are in. Every entry carrying the payload tag is resolved, by tag id rather than by record key, since tagIdOf() lets a caller name it anything and a spelling that escaped the guard would be a second spelling that disagrees about correctness. Rejected: refusing a string message_payload outright and demanding octets, which contradicts short_message on the same PDU. Rejected: guarding every string-valued field, which data_coding says nothing about — an address is a C-Octet String and ASCII by 3.4's own definition.

  • A GSM 03.38 message declares data_coding 0x00, and an inbound 0x01 is still read as GSM. Maintainer's call, 2026-09-09: dataCodingFor() and encodeBody() both resolved an alphabet through consts.ENCODING, so encoding: 'ASCII' went out as 0x01 — SMPP 3.4 5.2.19's IA5 (CCITT T.50)/ASCII — while the codec writes GSM 03.38, where $ is 0x02 and @ is 0x00 against IA5's STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the same codec. dataCodingByEncoding is the single answer to which coding an alphabet is written under, as unencodable() is to whether one can carry a message: the mirror of encodingByDataCoding(), and reached by both the sendSms() path and encodeBody()'s detected one rather than each spelling the map again, which is what sendDlr() inherits it through. It is exported for the reason unencodable() is — a caller pairing encodeMessage()'s octets with a data_coding of its own had only consts.ENCODING to reach for, which is the trap. 0x00 is the SMSC's default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee; it is also what every peer in interop-tests/ submits under and what LINK Mobility, Route Mobile and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01 to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no researched peer means IA5 by it. The two tables agree over most of the printable range and part at 0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed, carriage return and escape are common to both — which is where a peer that did mean IA5 is misread. Accepted with it: consts.ENCODING loses its ASCII alias and keeps IA5, the two names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different values and so the only one a reader could carry from the option's vocabulary into SMPP's flat table; constsById.ENCODING[0x01] already read IA5, so nothing moves but the forward name. Rejected: moving consts.ENCODING.ASCII to 0x00, which would make that table contradict the section it exists to spell — the group is SMPP's flat data_coding table, not the encoding option's vocabulary, the distinction the FLASH removal already drew. Rejected: reading 0x01 as Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing researched has found. Accepted: a message already in flight is unmoved — both codings resolve to the same codec, messageClassOf() finds no class in either, and Reassembler groups on the concatenation reference rather than on data_coding — so a receipt or a segment that crossed the change reads exactly as it did.

The session's life

  • A close arriving after our own unbind is a clean unbind, not an error. Maintainer's call, 2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated reason, which is accepted — the peer sees the same TCP close either way.

  • close means the session is over, and a drop the loop will retry is disconnected. Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on close ends up holding two binds on one account. 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. A retry that opens a socket and then loses it clears closed through attach(), which is why a second drop emits again.

  • An answer belongs to the link the message arrived on; a receipt does not. Maintainer's call, 2026-09-01. Rejected: answering on the new link, which succeeds and reports {} for a response that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused response names an id the peer has no record of.

  • reconnect takes { minDelay, maxDelay } to retune and false to turn off, so absent means on and there is one spelling for each. Only client() reconnects — a server() session is a connection the peer opened, and nothing at this end can reopen it. The retry timer is unref()'d, so a process with nothing else left to do still exits between attempts.

  • Coming up is not proof a link works, so only one that outlasted maxDelay resets the backoff. An unreadable stream is found after the bind returns, so resetting on connect gave a link that died on arrival a fresh minDelay every cycle — one TCP connect and bind per second, forever. A drop after a healthy link still retries at minDelay.

  • reconnect: { fromStart: true } puts the first connect and bind through that same loop, and client() then resolves only once it is bound. Maintainer's call, 2026-09-05: an application started before its SMSC is up otherwise writes that retry itself, around the one this library already owns. A field on reconnect rather than an option of its own, so the combination that would contradict false cannot be written at all — false carries no fields — and a top-level fromStart is refused by name rather than ignored. Nothing but the caller's signal ends the wait: a bound of its own would be a second spelling of a deadline the caller already writes with that signal, and giving up after one is what the default does. A bind the SMSC refuses is retried like any other failure — rejected: giving up on ESME_RINVPASWD and ESME_RBINDFAIL, which would have the initial attempts and a rebind disagree about what a refused bind means, and gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the rate goal 4 cares about. The attempts before the first link report nothing, because the session running one has not reached the application: disconnected would have no listener and close would be a lie. Its wait is the one retry timer that is not unref()'d, for the reason LinkGate's hold is not — it is awaited with no other handle, so a process whose only work is client() would exit unbound.

  • A stream this library cannot frame is a dead link; one PDU it cannot parse is not. Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a command_length below 16 or above maxPduLength leaves nothing that can say where the next PDU starts, so it tears the link down through teardown() and the reconnect loop retries it on a fresh socket with a fresh framer. Every other codec failure honoured command_length, so the stream is still in sync and the next PDU starts where it says — tearing the link down there cost one peer half its receipts and its MO to a reconnect loop (interop-tests/findings/01-smscsim.md), and left the peer waiting for answers it was owed. sessionError carries every failure of either kind, never coalesced or suppressed, so a peer that only ever sends garbage is visible in the log rather than silent.

  • A deliberate shutdown drains; an unusable link and an abort do not. close() and unbind() wait on the send window rather than the pending map — the map misses a segment still queued behind a full window, and finishing a half-sent multipart message is the point. The window counts slots, never outcomes, and empties on a drop too, where teardown() settles everything the link was carrying, which is why drain() reads closed before it reads the count. 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, so draining any of the three would only hold a socket open for the timeout. unbind() sends its own PDU through request() past both the window and the drain gate, because it must go out either way. shutdownTimeout stays a session option rather than a close() argument: server() builds sessions on the caller's behalf, so the option is the only composition point. SmppServer.close() reports each session's unfinished drain through serverError, because its own result says nothing but that the listener stopped.

  • 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). Goal 1 has the answer a real SMSC gives — one message_id per submit_sm, immediately — so the group's id base is generated when it opens and each segment is answered <base>-<n>, the notation 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, ESME_RMSGQFUL 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 6 does not buy a second exported class to make a count distinguishable. Accepted: a completing segment whose own answer the socket would not carry still reaches the application, because the message is whole and correct and the failed answer is on sessionError — a peer that re-sends after the drop is the smaller risk than dropping a message in hand. The answer goes out before the sms event either way, so a listener's own receipt can never precede the acceptance of the message it reports on.

  • server() composes the application's onRequest after its own bind handling, and offers it every request that handling did not answer. Maintainer's call, 2026-09-06, from a product review of the multipart change: server() filled the session's only onRequest slot, so the escape hatch the error above names was reachable only by hand-wiring a Session over a raw socket, giving up bind acceptance, authenticate, the session set and the drain close() runs over it — which is what goal 6 means by beating "the application can do this itself". What the library verifies is the ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind request on a session already bound, so no bind — a second one on a live session included — and nothing a peer sends before one can be intercepted however the hook is written. One OnRequest type on both option bags, because a second contract under one name is two spellings of one goal; widened to accept a plain boolean, as authenticate already is, so an observing hook need not be async. Nothing of ours is written for a request whose hook failed, the same on both surfaces: the library cannot tell one that failed before answering from one that failed after, so goal 2 reports the outcome as undetermined rather than guessing, and the peer's own responseTimeout is what settles it — the answer authenticate failing already takes. A hook that throws or rejects reaches sessionError on the way; one that never settles reaches nothing at all, and is visible only as the request that was never answered. That takes the keepalive with it, since a hook broken across the board leaves enquire_link unanswered and the peer drops the link — the back-pressure wanted, because an application that cannot serve a link should not hold one. sessionError rather than serverError because the failure belongs to one session's request, and that channel already carries every failure of one. The hook is consulted before the bind-direction gate, so it sees a submit_sm a receiver-bound peer may not send; first refusal means first, and one it declines still gets ESME_RINVBNDSTS. Nothing is held for a request the hook answered: HeldMessages is opened by the sms event the hook skipped, so the drain waits on none of it. OnRequest stays unexported where AuthenticateInput is exported, because that hook's argument is a shape this library invents and this one's are two types already published. Rejected: consulting the hook first, which puts bind and authentication inside the application's reach for nothing. Rejected: a narrower hook returning a status for the library to write, which makes the answer verifiable but pays a second contract under a second name for it, and could not express what the session-level hook already does — answer a bind, a vendor command, a data_sm — leaving that error naming something only half the surface can do. Rejected: falling the request through to the built-in handling on a failure, which reads as the answer the peer would have had with no hook — true only of a hook that failed before answering, where one that failed after put a second response on the peer's own sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the fall-through could be gated on it, which buys a fail-open path with state and an internal contract no other collaborator needs.

  • The drain waits on the messages the application holds, and sendResp() is what says it is done with one. Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session down while the application was still answering a submit_sm, so the peer timed out and re-sent — the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was added to the sms event: sendResp() is what an application already calls when it is done with a message, so it is the one the drain waits for. Counting every inbound request until sendReturn() answered it was rejected — an onRequest that deliberately answers nothing would then cost a full shutdownTimeout on every close — and a message no listener took is released at once, since nothing is going to answer it. A listener that failed before answering gives it up the same way, but only once every listener has: a throw stops emit() where it stands, while a rejection leaves the others running, so the release waits for the last of them rather than answering on their behalf. What ends the wait is the response reaching the wire, not the call — a sendResp() the library refused, or one the socket would not carry, leaves the message held, so close() still reports the one the peer is owed. Where the segments were answered as they arrived there is no response left to write, so the call itself ends the wait, an argument the library refuses excepted. teardown() drops what is still held for the same reason it drops inbound segments. The release is one turn late, so a listener that sends its receipt straight after the response is still holding when the drain looks; sendDlr() is the one send that goes out past the drain's refusal, and only while the message is still held — past that it is an ordinary send, because the drain it would slip past is no longer waiting for it. shutdownTimeout: 0 does not carry over to this half: 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 the link gate'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 is held is capped and expiring like every other inbound store, on constants rather than options, because a bound the application cannot raise is the point: an application that answers nothing would otherwise grow it for the life of the link, which goal 4 forbids. A message that falls out of the bound is one the drain stops waiting for, so close() can report fewer unanswered than there were — accepted, because the alternative is holding what nothing will answer, and both exits are logged.

  • A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. onDelivery() answers each receipt before the group it belongs to is complete, and teardown() runs on every path — an idle timeout and a failed rebind, not only close() — so clearing the merges there loses receipts no peer has a reason to send again. They are cleared where the session is over instead. Inbound segments stay in teardown(): a concatenation reference is the peer's own counter, so a half-arrived group kept across a drop would take a later message's segments as readily as the rest of its own, and goal 2 will not hand the application a message assembled that way. What goes there is traffic already answered, which is why each group reaches sessionError like every other one given up on.

  • A message id base is merged at most once. A receipt carries nothing but <base>-<n>, so a straggler for a message whose group is gone cannot be told from a receipt for a later message the peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic case. DlrMerger remembers the bases it has finished with, capped and expiring exactly like the groups, and refuses to open one a second time: the later message gets no messageDlr, and an earlier one whose receipts are still arriving is dropped rather than left to collect the later one's. Every segment still reaches the application as a dlr. expect() ignores a lone id, so a single-part message never claims a base.

  • A send that never reached the socket waits for the next link; one that did is counted, not resent. Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a submit_sm the SMSC accepted and answered into a dead socket, which is delivered and billed twice, while a request that never left this process can be lost for free. attempt() therefore wraps all three ways a written request can fail in UnansweredError; counting only the dropped-link case, as the first cut did, would have called the commonest one safe to resend. A count rather than a boolean because sendSms() aggregates segments into one err slot, and required rather than optional so every construction site answers. UnansweredError stays unexported: unanswered is the one spelling on the public surface. The hold is bounded by responseTimeout rather than an option of its own — that is already the answer to how long one request may wait — and its clock starts when the send is issued rather than when it first finds the gate shut, so one budget covers every hold a single call makes. That timer is the one here that is not unref()'d: a held request is awaited with the socket already destroyed, so an unref'd one lets a process whose only remaining work is that send exit without settling it.

  • A send queued for a send-window slot is bounded by the caller's signal, and by nothing else. Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the acquire() on the next line did not, so a caller that aborted while the window was full waited for a slot it no longer wanted — at responseTimeout: 0 for as long as the peer stayed quiet, which is the deadline the README sends the caller to that signal for. Goal 4 is not re-opened by an unbounded wait here: the queue is the application's own backlog, unbounded in depth as well as in time because capping it would refuse a send the application asked for, and nothing in it keeps the peer waiting — which is what separates it from the inbound stores capped on constants. Rejected: having release() skip a waiter whose signal already fired, which leaves the departed waiter in the queue where unfinished() still counts it and the drain waits on it; the waiter leaves as it settles instead. Rejected: bounding this wait by responseTimeout as the hold is bounded — a full window is this end's own concurrency draining as the peer answers rather than a link going nowhere, and that bound would fail a message with more segments than maxOutstanding partway through against a slow peer. The failure is a plain Error rather than UnansweredError, the same answer an abort at the gate 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.

  • The gate decides whether a link can carry a request, and a bind is what makes it one. Maintainer's call, 2026-09-01: attach() clears closed the moment a socket is handed over, one round trip before the bind is answered, so gating on closed let a send arriving in that window go out unbound and come back ESME_RINVBNDSTS. LinkGate owns the answer instead — shut(returning) on every teardown, open() only once comeBackUp() has a bound link — and OutgoingRequests.linkDown() reads it rather than closed. The bind itself cannot wait for what it creates, so pastDrain() lets the three bind commands past the gate and the window, the same door unbind() takes through now(). The gate is told what happened and never reads back into the session: a collaborator that has to ask does not own its decision, which is how the first cut ended up answering the same question two different ways at admit and at release. For the same reason the retry in pastDrain() asks gate.isUp() rather than linkDown(), which also reads the socket — a condition that loops on something the gate does not gate on spins against a gate that admits it straight back. LinkGate.returning is a copy of retrying() taken at teardown, and stays true only because nothing stops the reconnect loop without emitClose() following it: drain() and end() are the only callers of stop(). A third caller has to shut the gate itself.

Internals and tests

  • A listener that rejects is routed by Node's captureRejections, not by hand-dispatching. Both emitters construct with captureRejections: true and implement [EventEmitter.captureRejectionSymbol], which lands a rejected async listener on sessionError or serverError beside the synchronous guard in emit(). Dispatching rawListeners() from emit() instead needs a cast to call them with the event's argument tuple, which hard rule 4 forbids. A rejection reason is unknown and String() throws on a null-prototype object, so both handlers normalise through errorFrom() rather than inline — a route out of the handler would land on a bare process.nextTick with nothing to catch it.

  • The four-line abort dance is copied across LinkGate, IdleWaiters, PendingRequests and SendWindow rather than extracted. Architecture review, 2026-09-06: pre-check aborted, attach { once: true }, detach on settle, leave the registry. What differs at each site is the registry and what settling means — a FIFO handing over a slot, a set released together, a map keyed by sequence number, a count recomputed at settle — so a shared Waiters<T> fits two of the four and is a shallower module than the copies. Extract it once a fifth appears.

  • SmppLog is a five-method contract this library declares, not a dependency. debug, error, info, verbose and warn are what the code actually calls, so an application can satisfy it with an object literal. @larvit/log implements it structurally and stays a devDependency, where test/tls.test.ts passing a real Log as the server's logger keeps that compatibility compiled.

  • The TLS tests build their own self-signed certificate in DER (test/tls.test.ts) instead of adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image node:24.18.0-bookworm-slim ships no openssl binary, so a shelled-out fixture would pass in CI and fail on every developer machine, and a committed key leaks in a public repository. Valid while the dev image has no openssl.

  • src/ stays flat until a module has to move for another reason. Architecture review, 2026-09-06: the grouping the file map above 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.

  • 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 is the naming rule rather than a tree — operator-receipts.test.ts holds a corpus defined by where it came from, cutting across four modules, where filing it by module would enter each new operator twice. A split also has to be made twice, since test and test:compiled each carry a path of their own. The four files that are not tests are the exception the rule needs stated: dummy-smsc.ts, raw-pdus.ts, reference-smpp.d.ts and teardown.ts answer no question and are named for what they hold.