Files
smpp-js/AGENTS.md
T
lilleman 9fb8f348c8
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 22s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 36s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 30s
Test / test (18) (pull_request) Successful in 30s
Drop the alternate TLV names from tlvs, correct the false doc claims the prose pass found, and file the moves
2026-09-27 15:36:56 +02:00

23 KiB

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

The goals, in priority order, live in README.md — they say where this library is heading, which an outside reader judges it by. The README states the audience alongside them.

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
	retained-pdu.ts      A PDU copied off the wire so holding it pins nothing else, and what holding it costs
	send-sms.ts          submitSms composition and the submitSmParams builder
	send-window.ts       SendWindow: the maxOutstanding semaphore
	session-options.ts   SessionOptions, ReconnectOptions, bind direction and the session defaults
	sms-id.ts            Message ids: the peer's notation, the <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 typed read and input shapes, and reading and writing a TLV stream
		types.ts         Wire types: int8/int16/int32/string/cstring/buffer/arrays

Imports point one way: defs knows nothing above it but result.ts, pdu uses defs, session uses pdu, and client/server use session. The one way back up is the Session handed to IncomingRequests and createSms(), imported as a type only.

Parameter order is wire order. The key order inside cmds.*.params is the order the fields are written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering 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
  • 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; the dev container runs Node 24 because type stripping needs it.
  • 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

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. 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 socket a test opens and never reads must be resume()d, and a data listener counts. 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, and where this is heading. Observable behaviour, for someone using the package, plus the goals and the audience. It carries a reason only where the reason changes how you would call the thing.
  • CHANGELOG.md — what changed for a consumer, per release. Written for the public, never for the next agent, and a line lands there as the work ships rather than at release.
  • 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. Hard rules, architecture, conventions, and an index of the decisions. It does not restate behaviour or goals README states.
  • docs/decisions.md — what was settled, and against what. The decisions the goals do not already settle, each with the constraint that settled it and the alternative rejected.
  • 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. It goes in docs/decisions.md with its title indexed below. Delete one once it no longer constrains anything; this is not a changelog.

Decisions

The decisions themselves live in docs/decisions.md. Their titles are indexed here, so a reader sees that a decision exists without carrying its reasoning; the reasoning is in the file.

The public surface

  • Session is publicly constructible, which is what makes SessionOptions and ReconnectOptions public too.
  • acceptsOptionalParams() and bindAllows() are predicates, not chokepoints.
  • Both emitters re-declare their listener methods to accept a promise.
  • PduRefusedError is exported, and sessionError names it in the event's type.
  • bitCount(), encodeMessage() and splitMessage() keep their total signatures, because EncodingName is what keeps an alphabet with no codec away from them.
  • A segment the SMSC took and named no id for is undefined in smsIds, not an empty string.

The wire

  • The declared interface version is an option on both client() and server(), and is not the optional-parameter threshold.
  • A peer that declared no version is pre-3.4, and undefined means no bind yet.
  • esm_class decides what a deliver_sm is, and the body is read only when it names nothing.
  • A body is read from message_payload where short_message carries none, and short_message wins where a peer filled both.
  • 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.
  • sendSms() takes the messaging mode by name, and it is the only part of esm_class a caller writes.
  • An inbound data_sm stands in for whichever of submit_sm and deliver_sm its direction makes it, and none goes out.
  • A receipt's body is read as octets, and its own data_coding never says how.
  • 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.
  • A report is final unless its esm_class or its state says otherwise, and only ENROUTE and SCHEDULED say otherwise.
  • 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.
  • A transient state goes out as an intermediate delivery notification (0x20), every other state as a delivery receipt (0x04).
  • A refused PDU is answered from its header, and any 32-bit sequence_number is echoed as it arrived.
  • The optional parameters run to command_length exactly, and the only slack tolerated is one NULL octet where a peer padded short_message.
  • smsIdFormat names a notation per place, and normalisation never reaches inside a <base>-<n> id.
  • 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.
  • 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.
  • 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.
  • A GSM 03.38 message declares data_coding 0x00, and an inbound 0x01 is still read as GSM.
  • Every text field on the wire is latin1, and what the field cannot carry is refused rather than truncated.
  • A TLV input is keyed by its tag name, or by its decimal id where the table names none, and a tagId beside the key is accepted only where it agrees.

The session's life

  • A close arriving after our own unbind is a clean unbind, not an error.
  • close means the session is over, and a drop the loop will retry is disconnected.
  • An answer belongs to the link the message arrived on; a receipt does not.
  • reconnect takes { minDelay, maxDelay } to retune and false to turn off
  • Coming up is not proof a link works, so only one that outlasted maxDelay resets the backoff.
  • reconnect: { fromStart: true } puts the first connect and bind through that same loop, and client() then resolves only once it is bound.
  • connectTimeout defaults to 10 s, bounds the whole connect including the TLS handshake, and false is the one way to turn it off.
  • A stream this library cannot frame is a dead link; one PDU it cannot parse is not.
  • A deliberate shutdown drains; an unusable link and an abort do not.
  • sendSms() puts every segment of a message on the wire together.
  • Every segment of a concatenated message is answered as it arrives, so sendResp() on one is the application's own signal rather than the peer's answer.
  • server() composes the application's onRequest after its own bind handling, and offers it every request that handling did not answer.
  • The drain waits on the messages the application holds, and sendResp() is what says it is done with one.
  • The drain's wait on the application ignores shutdownTimeout: 0.
  • What the application holds unanswered is capped on constants, and a message past the cap is refused.
  • A store at its bound answers ESME_RTHROTTLED to a submission and ESME_RX_T_APPN to a delivery, a data_sm by whichever it stands in for.
  • A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.
  • A message id base is merged at most once.
  • A send that never reached the socket waits for the next link; one that did is counted, not resent.
  • A send queued for a send-window slot is bounded by the caller's signal, and by nothing else.
  • The gate decides whether a link can carry a request, and a bind is what makes it one.

Internals and tests

  • A listener that rejects is routed by Node's captureRejections, not by hand-dispatching.
  • The four-line abort dance is copied across LinkGate, IdleWaiters, PendingRequests and SendWindow rather than extracted.
  • SmppLog is a five-method contract this library declares, not a dependency.
  • The TLS tests build their own self-signed certificate in DER
  • src/ stays flat until a module has to move for another reason.
  • test/ stays flat too, and a file there is named for the question it answers rather than for the module it covers.
  • CI tests on Linux only; src/ keeps off what is known to break on macOS or Windows.
  • GitHub mirrors Gitea without pruning, and a ref deleted on Gitea is deleted on GitHub by a run of its own.