Files
smpp-js/todo.md
T
lilleman bf1bddba3b
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 29s
Test / test (20) (pull_request) Successful in 29s
Test / test (22) (pull_request) Successful in 30s
Test / test (24) (pull_request) Successful in 29s
Test / test (26) (pull_request) Successful in 34s
Name files for what they export, and move the session files into session/
2026-09-30 20:33:13 +02:00

55 KiB
Raw Blame History

todo.md

Remaining work for @larvit/smpp. Read README.md's goals and AGENTS.md's hard rules first — they constrain every item below.

This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not govern it, and nothing here is a source anything else may cite.

Status

The rewrite is feature complete and green: the suite, lint and typecheck are clean on Node 18 to 26, and 0.5.0 is on npm. 0.6.0 is next, and it is a quality cut rather than a feature one — the comprehension gate and the defects under 0.6.0 come first, then the gaps a comparison with other SMPP libraries found.

The agreed API

Settled with the maintainer before implementation. Do not change any of it without asking. The public surface is documented in README.md; this is the short form.

import { client, server } from '@larvit/smpp';

const { err, session } = await client({ host, password, port, username });
const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to });
await session.unbind();

const { err: serverErr, server: smpp } = await server({ authenticate, port });
smpp.on('session', session => {
	session.on('sms', async sms => {
		await sms.sendResp();
		if (sms.dlr) await sms.sendDlr('DELIVERED');
	});
});
await smpp.close();

Rules the API follows:

  • Never throws. Everything fallible resolves to { err?, … }. See AGENTS.md rule 1.
  • Named exports only, no default export. defs is exported as a group alongside the individual tables.
  • The PDU codec is synchronous and returns { err?, pduObj? } / { err?, buffer? }.
  • Low-level surface stays public, including session.sock, session.send() and session.sendReturn().

Done

Covered by
Definition tables: constants, errors, encodings, wire types, TLVs, commands test/encodings.test.ts, test/types.test.ts, test/commands.test.ts
Message helpers: splitting, bit counting, SMPP dates and times test/message.test.ts
PDU codec: parse, build, respond, per-command typing, bounds checks test/pdu.test.ts
Stream framing test/pdu-framer.test.ts
Delivery receipt parsing, TLV and text test/dlr.test.ts
Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window test/session.test.ts
Merged multipart DLRs including across a reconnect, reassembly bounds, per-send abort, the segment cap test/session-extras.test.ts
smsIdFormat: a peer's submit_sm_resp and receipt ids read into one notation before they are compared test/dlr.test.ts, test/session-extras.test.ts
A draining close() and unbind(), bounded by shutdownTimeout or an abort test/session-extras.test.ts
A drain that also waits out the messages the application has not answered, with sendDlr() the one send that passes it test/session-extras.test.ts
OutgoingRequests: the gate, the window, the pending map and the retry under one owner, told when a link comes up or goes down test/session-extras.test.ts, test/session.test.ts
Held messages capped and expiring, so an application that answers nothing cannot grow them test/session-extras.test.ts
A send with no link held for the next one, and one the link dropped under counted as unanswered test/session-extras.test.ts
A message whose link dropped refused an answer, with its receipt still allowed out test/session-extras.test.ts
The hold released exactly when the peer was answered: a refused sendResp() keeps it, a listener that rejected drops it test/session-extras.test.ts
Every runnable README example test/readme.test.ts
Receipt-versus-message classification by esm_class test/dlr.test.ts, test/session.test.ts
An intermediate delivery notification read as a report marked intermediate, as is a receipt reporting ENROUTE or SCHEDULED, and never counted into a merge test/dlr.test.ts, test/session.test.ts, test/session-extras.test.ts
A transient state sent under the marker the spec gives it, off the same list the reader uses test/session-extras.test.ts
A listener that throws, or rejects, reaching sessionError/serverError rather than the process test/session.test.ts, test/error-from.test.ts
Cross-checked against node-smpp both ways and over a live session test/interop.test.ts
CI on Node 18 to 26, Renovate, tag-triggered publish .gitea/workflows/

Every defect listed in the AGENTS.md table has a regression test naming the behaviour.

Move the repository to Gitea

gitea.larvit.se/larvit/smpp-js is the repository. github.com/larvit/smpp-js mirrors it and is the place issues are filed. Maintainer's calls, 2026-09-13 and 2026-09-14.

  • Move main and v0.4.0 to larvit/smpp-js. It holds main, from typescript, and v0.4.0, from master. rewrite-base and the renovate/* branches stayed behind.
  • Protect main with fast-forward merges and required checks. Fast-forward is the only merge style. main takes no pushes, requires Test / lint (pull_request) and Test / test (*) (pull_request), blocks an outdated branch, and gives admins no override. Every other branch takes force pushes.
  • Run the workflows from .gitea/workflows/. Tests run on pull requests only, the event the gate reads; Renovate runs as a scheduled workflow, as on adf-codec.
  • Publish the release without provenance. npm generates it only on GitHub Actions and GitLab CI/CD.
  • Name Gitea in package.json, and the GitHub mirror's issues as bugs. The README links absolutely: npmjs.com resolves a relative link against itself when the repository is not on GitHub. Its test badge is gone, since Gitea reports a workflow's status per branch and no workflow runs on main.
  • Add a github.com token for Renovate only if its lookups hit the rate limit. RENOVATE_GITHUB_TOKEN exists nowhere, so Renovate queries github.com unauthenticated, as adf-codec's nightly run already does without a warning. RENOVATE_TOKEN is the Gitea token and cannot stand in for it.

Before publishing 0.5.0

  • Publish the rewrite as 0.5.0, not 1.0.0, while usage is this low. Maintainer's call, 2026-09-14.
  • Hold NPM_TOKEN as a Gitea organization secret. .gitea/workflows/release.yaml needs it.
  • Tag v0.5.0 on Gitea to publish. The first publish creates @larvit/smpp on npm, provided the token can publish under @larvit.
  • Deprecate larvitsmpp on npm, pointing at @larvit/smpp. npm deprecate larvitsmpp is the maintainer's call to run; not something CI should do.

Retire the GitHub repository

Nothing here starts before 0.5.0 is published. Maintainer's call, 2026-09-14. Then in this order: deleting GitHub's old branches closes every pull request based on them without a reply, and GitHub refuses to delete its default branch.

  • Close the backlog below.
  • Close #71, pointing at Gitea.
  • Rename larvit/larvitsmpp to larvit/smpp-js. GitHub redirects the old URLs, and the bugs URL in package.json resolves from then on.
  • Push main and make it GitHub's default branch.
  • Set Renovate to Silent for this repository in the Mend Developer Portal. It then opens nothing on GitHub while the organization-wide installation stays. CodeRabbit stays installed. Maintainer's call, 2026-09-14.
  • Mirror to GitHub from .gitea/workflows/mirror.yaml and mirror-delete.yaml, with MIRROR_GITHUB_TOKEN. A push of a commit carrying the workflow, and the nightly run, send all of Gitea's branches and tags, overwriting a same-named ref; a branch or tag deleted on Gitea is deleted there too. Refs only GitHub has stay. Maintainer's call, 2026-09-14.
  • Strip GitHub's repository to a mirror, and give both forges the package's About. GitHub's wiki, projects and Actions are off, the Travis app and webhook are gone, and its About matches the package. Gitea carries the same description, website and topics, and sends issues to GitHub as its external tracker.

Close the GitHub backlog

Answer and close as fixed by 0.5.0, the reply naming what fixed it:

  • Close #2, tests for the README examples. Fixed by test/readme.test.ts.
  • Close #3, tests for flash messages. Fixed by test/session.test.ts.
  • Close #4, DLR errors with message_state missing. dlrFromPdu() parses the stat: receipt text when the TLVs are absent.
  • Close #13, limit a long SMS to fewer segments. Fixed by the maxSegments send option.
  • Close #16, support all three bind types. Bound and enforced in both directions.
  • Close #17, addr_ton/addr_npi should be settable. sendSms() takes all four, documented and tested.
  • Close #20, tests fail on current dependency versions. The mocha suite is gone; node:test on Node 18 to 26.
  • Close #33, large inbound text arrives as raw Buffer segments. IncomingRequests reassembles a UDH-carrying deliver_sm into one sms event.
  • Close #68, a pull request for message_id in submit_sm_resp and spec DLR codes. All four hold: sendResp() always answers a message_id, per segment; stat:UNDELIV is the 7-character code. Credit the reporter — the fork found real defects.

Close as superseded, all against 0.4.0 dependencies the rewrite does not have — async, coveralls, eslint, iconv-lite, larvitutils, mocha, mocha-eslint, portfinder, uuid:

  • Close the twelve dependency pull requests as superseded. #40, #41, #42, #45, #46, #47, #59, #63, #64, #67, #70, #77. #70 is the open uuid advisory GitHub reports on the default branch; it disappears with the runtime dependencies rather than being fixed.

#60 is Renovate's dashboard and stays open after the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.

Close as tracked here, the reply saying it will be implemented on Gitea:

  • Close #8, the socket's remote host and port on log messages, as tracked here. It sits under Worth doing, not blocking. Maintainer's call, 2026-09-14.

0.6.0

A nine-reader comprehension panel read the whole project on 2026-09-20 and scored it 7 overall, mean 6.8. Navigation (7–8) capped nobody. Locality capped every unit reader at 5–6 and Shape capped both architects at 6, and those two are what this release lifts. The gate is 7 on all four dimensions, higher where it is cheap. Maintainer's call, 2026-09-20. A systems-architect review the same day returned ALIGN with one blocking-severity finding, resolveBody's settles boolean, which the panel also ranked hardest; it is now a named CodingSource.

A four-seat scoring run on 2026-09-27 read #30 at 6, 6, 7 and 6, every seat capped by Locality in the held-message and shutdown code; #30 merged under the floor on condition that Locality is the next work (decision).

Locality — the plan-3 rewrite, next and ahead of everything below; 6.25 today, and the gate is 7

The scoring run after #49 read 6, 6, 7 and 6, Locality 5, 5, 6 and 6. Three rounds of redesign drafts (A–F) each took the hardest unit off the panel's list and exposed the next, and none moved the mean past 6.25. A four-seat board then read three from-scratch plans. It ranked plan 3 first unanimously and predicted 6, 7, 7 and 7, and every seat judged the ceiling to be the code's structure, not SMPP's difficulty. Maintainer's call, 2026-09-30: build plan 3. The background is in docs/comprehension-rewrite/: the plans, the board, every panel report, the lessons and the six drafts as patches. Delete that directory once this milestone ships.

Every chunk below ships through /larv-review, and before it merges it also runs the comprehension-panel scoring run over the whole project. Each round:

  • fix what the seats name inside the chunk's own area;
  • ask the seats what would lift the bar further, and file what lies outside the chunk here;
  • let no dimension drop, and record the four scores and the overall in this paragraph.

Each chunk also closes the items further down that it absorbs, and its PR names them: hold and refusing, ASCII, and the link that dropped mid-rebind.

Every chunk that changes the public API updates README's examples, MIGRATION.md, CHANGELOG.md, docs/decisions.md and the AGENTS.md map in its own PR, and deletes what it replaces; no old and new file stand side by side. The architecture review of 2026-09-30 (ALIGN) set this order; its amendments to the plan are plan 3 §8.

  • Build protocol/: every multi-fact octet read once into a named plain type. - vocabulary.ts: the glossary as types with one-line TSDoc. - data-coding.ts: the data_coding table as rows, with a test that it equals today's function on all 256 octets. - esm_class, segments, receipts, message ids, time and bind; no state, so the segment reference counter goes to SmppClient in the lifecycle split. - The gsm7 rename, A6 included: 'ASCII' becomes 'GSM7' in every export. - A test that fails on a spec citation without its sentence. - Settle where encodeBody() and decodeMessage() land before splitting message.ts: codec/pdu.ts reads them, so a home in protocol/ is an upward edge.
  • Build messages/ on a BoundedStore that enforces its own bounds. Reassembly refuses at its bound instead of evicting, recorded against goals 2 and 4; the receipt merge follows Q8 and its spent set expires by age. README's bound text is updated.
  • List every test by name, mapped to its survivor or a reason for deleting it, before the lifecycle split. Every defect-table regression test survives by name.
  • Split the lifecycle (A1, A5): a one-socket Session, reconnect above it in client/. - SmppClient, connect.ts, backoff.ts, and client/next-link.ts holding the wait for a bound session and the retry of only what never reached the socket, invariant at the top, so client.ts only composes. - sendResp() and HeldMessages are ported as they are. - Absorbs registering a multipart send's receipt merge before its segments go out. - The interop suite and goal 6's benchmarks run before it merges.
  • Answer on return (A2, A3, A4). handlers.ts, requests-in.ts returning a Reply, onRequest returning one, server/ ported, HeldMessages deleted. The interop suite and the benchmarks run before it merges.
  • Confirm Locality at 7 with a final scoring run. A run reading 7.0 or above retires the Locality-first decision.

Correctness

  • Bound a rebind's wait when responseTimeout is 0. comeBackUp() arms no timers until the bind is answered, so an SMSC that takes the TCP connection and never answers leaves ReconnectLoop attempting forever: no close, no next attempt. answering() already falls back to the default for held messages; the bind has no such floor. From the 2026-09-30 scoring run; confirm with a test first.

  • Type a parsed short_message as what the parser puts there. parsePdu() replaces it with a decoded string wherever no UDH is present, while PduParams<'submit_sm'>['short_message'] is Buffer and isCommand()'s docstring calls the narrowing sound. Public type; from the 2026-09-30 scoring run.

  • Refuse to open a link that dropped while its rebind was answered. A peer sending bind_resp and FIN together can tear the link down before comeBackUp() resumes; it then calls link.open() on a down link, resetTimers() skips, and attempt() reports success, so the session is up on a destroyed socket with nothing to reconnect it until close(). open() accepting only binding, and comeBackUp() returning an err when the link is no longer attached, lets the loop retry. Unreproduced; from the stability review of #48.

  • Register a multipart send's receipt merge before its segments go out. Session.sendSms() calls dlrMerger.expect() only once submitSms() resolves, after the last segment's response, so a receipt for an early segment that arrives first is logged at debug as naming no merge, and the group then waits out dlrMergeTimeout with no messageDlr. Likeliest with a fast SMSC or more segments than maxOutstanding. Goal 2. From the 2026-09-28 scoring run on #48.

  • Settle what a repeated tag not marked multiple reads as, and pin it in a test. A vendor tag or a known single-value tag a peer sends twice keeps the last occurrence and drops the rest silently, which goal 3 argues against; listing it would change every such tag's shape. From the architecture review of #25.

  • Test that a multipart send which errors never fires messageDlr. Goal 2 now says so and README promises it; session-extras.test.ts covers a drop after the send, not one during it.

  • Return an err where message is not a string, rather than throwing. sendSms({ message: undefined }) — a forgotten property — reaches value.replace() in codec/encodings.ts through the alphabet detection checkOptions() runs, and the TypeError escapes submitSms() into the caller's process; NaN and 12345 do the same. README promises "Never throws. Every fallible call resolves to { err?, … }" and AGENTS.md hard rule 1 says it again, so the docs are false for the likeliest caller mistake there is. From the stability review of #18.

  • Derive sm_length for a numeric body, or refuse one. resolveBody() in pdu.ts reads the length only where the body is a Buffer or a string, so objToPdu({ cmdName: 'submit_sm', params: { short_message: 12345 } }) writes sm_length: 0, then five octets after it, and reports success — and this library's own parser refuses what it built, as "TLV 12594 runs past the end of the PDU". Goals 1 and 2. From the stability review of #18.

  • Settle which numbers may spell a text field, refuse the rest, and say so where a consumer reads it. wantText() takes every finite number through String(), so message_id: 1e21 writes 1e+21, from: 0.1 + 0.2 writes 0.30000000000000004 and source_addr: -5 writes -5 — none of them is the id or the address the caller meant, and all three are reported as sent. The numeric branch exists for a digit sequence (message_id: 123); the product-owner review of #18 recommends Number.isSafeInteger(value) && value >= 0 with the refusal naming the fix, since a 64-bit SMSC id loses digits to a JS number before this library ever sees it. Goals 2 then 3: from: 1e21 is reported as sent to an address that reaches nobody, which is the wrong answer about what happened before it is laxness in what we send. That a number is accepted at all reaches a consumer in no sentence either: only the type comment at codec/commands.ts, and one CHANGELOG line that stops being visible when 0.7.0 is cut, while README's Building bullet reads as the whole rule for a text field. Whether this is a supported spelling or 0.4.0 tolerance decides whether that sentence lands in README.md or in MIGRATION.md — write it in the same change as the rule, so it is worded once. From the stability and product-owner reviews of #18.

  • Build every error from a thrown value through errorFrom(). reconnect-loop.ts (in run()'s catch and in bringUp()) and client.ts's connect still call String(thrown), which throws on a null-prototype object; in run() that lands as an unhandled rejection from a voided promise, against hard rule 1. From the 2026-09-28 scoring run.

  • Leave sms.smsId alone when sendResp() fails. sms.ts assigns options.smsId before the lost-link check and before the write, so a failed answer still renames the message and a later sendDlr() names an id the peer was never given. From the 2026-09-28 scoring run.

  • Refuse a timeout past 2³¹−1 ms, as connectTimeout already is. checkLimits() bounds responseTimeout, idleTimeout, shutdownTimeout and reassemblyTimeout from below only, and Node fires a larger delay after 1 ms. From the 2026-09-28 scoring run.

  • Keep a bare ESC out of GSM detection. gsmRegex in codec/encodings.ts admits \x1B, so "\x1B(" is detected as GSM, goes out as 0x1B 0x28 and arrives as {. From the 2026-09-28 scoring run.

  • Count an sms listener that throws as one giving up, as a rejection already is. A synchronous throw makes Session.emit return false, and HeldMessages.offer() then releases the hold at once, so a drain stops waiting on an async listener still answering beside it. Goal 2. From the stability review of #45.

Throughput — goal 6, and the default window is where we are slowest

  • Close the gap to jsmpp at maxOutstanding: 10. Measured 2026-09-20 against the same sink, 100,000 messages each: this library 25,358/s, jsmpp 30,771/s, Cloudhopper 27,945/s — we are last at the one window most callers will ever run, while leading Cloudhopper and trailing jsmpp by only 5% at 50 and 200. So the cost is not the codec, which the higher windows exercise just as hard; it is something per-request that the window hides once enough requests overlap. benchmarks/ reproduces all three. Goal 6.

Shape — 6 today, and the gate is 7

  • Answer "is this a bind command" in one place. bindCommands (read by session/incoming-requests.ts, session/outgoing-requests.ts and test/session.test.ts) and bindTypeFromCommand() (read by server.ts and checkedBind()) each list the three bind commands, so a fourth added to one is missed by the other. Derive the list from the function, or the reverse. From the stability review of #42.

  • Split test/session-extras.test.ts by the question each block answers. 3,010 lines, 19 unrelated describe blocks whose names are already the file names they should be. With session.test.ts it is 54% of all test code and 84% the size of src/. "extras" names neither a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly that. max-lines covers src/** only, so nothing has stopped it growing.

  • Give hold one meaning, and rename IncomingRequests.refusing for what it does. LinkLife.hold() is a request's budget waiting for a link, HeldMessages.hold() a message the application owes an answer; refusing decides no refusal — held.full() does — and only makes the warn and info lines fire once each way. From the 2026-09-29 scoring run.

  • Rename EncodingName's ASCII to GSM7, with ASCII a deprecated alias for one minor. It is GSM 03.38, where $ is 0x02 and @ is 0x00, and segmentUnits.ASCII = 153 is a septet budget under a name that says octets. The 2026-09-09 decision removed consts.ENCODING.ASCII for exactly this reason and left the option's own vocabulary carrying it. Pre-1.0 the minor is the breaking unit, so this is as cheap as it will ever be, and todo.md already requires the consts.ENCODING names settled before the custom-encoding registry — this is the other half.

  • Name the base-versus-segment distinction in the message id types. Sms.smsId is a base, sendSms().smsIds[] are segment ids, Dlr.smsId is a segment id and MessageDlr.smsId is a base again — four fields, one type, string. The whole multipart receipt mechanism turns on telling them apart and only parseSegmentId() knows.

Self-sufficiency — 6–7 today, and the gate is 7

  • Move the fixture-copy reasoning out of AGENTS.md's Conventions, and split the longest decision entries. The fixtures bullet holds four justifications for tolerated copies — decisions, so they belong in docs/decisions.md under Internals and tests; the entries under the wire's alphabet and body rules run 30–40 lines with their Rejected: clauses inline, where a reader who knows the answer still hunts for it. From the 2026-09-29 prose pass.

  • Move the one-line facts out of the decision log and back to the code. Five of nine readers independently reported being sent to docs/decisions.md for a question they hit while reading, with no link from the code; one counted roughly fifty index redirects. The three left to inline as one line each: that segmentUnits' three numbers are in two units (septets and octets), that a receipt's body is read as octets whatever its data_coding says, and the <base>-<n> id notation. The reasoning stays in the log; the definition belongs at the code.

  • Document the two delivery-receipt merge bounds. maxDlrMerges (1000) and dlrMergeTimeout (24 h) are hardcoded, are not options, and appear in no README and no test — while README states the equivalent held-message bounds explicitly ("Neither bound is an option"). A sender with more than 1000 concurrent multipart dlr: true messages silently evicts the oldest at warn. The inherited architect hit this on the 3am walk.

  • State in README that SmppServer.close() reports each session's unfinished drain as serverError. Only docs/decisions.md says so; README's Shutdown section covers the session's own result alone.

  • Add a ten-line SMPP glossary to the README. Both juniors and the no-domain mid reported the same largest cost: nothing in the repo says what a PDU, esm_class, data_coding, TON/NPI or submit_sm-versus-deliver_sm are, and the inline spec citations mark a rule without stating it. One of them put it at a third of their reading time. Four commands, three octets, one sentence each.

Doc claims this review falsified

  • Log-cap every interop peer and probe Kannel by protocol, as interop-tests/AGENTS.md says every peer is. compose.kannel.yaml and compose.smscsim.yaml carry no logging: block, and Kannel's healthcheck is the bare TCP probe that file warns against. From the prose sweep of #46.

  • Cut what the prose sweep of #46 found restated or misplaced. docs/decisions.md entries of 30–45 lines carrying pre-fix history (133–160, 217–255, 362–402, 404–440, 442–472, 593–624, 626–662); AGENTS.md's 14-line shared-fixtures bullet, whose tolerated-copies reasoning is a decision; the defect table rows MIGRATION.md already carries; README's error-event reason (hard rule 3 owns it) and the Audience bullets restating goal 8 and Install; the 'use strict' clause in both MIGRATION.md and CHANGELOG.md; the node-smpp cross-check in MIGRATION.md; the planned work in interop-tests/AGENTS.md (an expected malformed count per peer) and benchmarks/README.md. The prose sweep of #48 adds: the smppload note in both benchmarks/README.md and interop-tests/README.md; the summary after the AGENTS.md link in interop-tests/README.md; the run.py foreground rule tacked onto rule 5 in interop-tests/AGENTS.md, which wants its own number.

  • Give this library one figure at window 50 in benchmarks/README.md. Its "same sink, same host" table reads 37,125/s where the table below it reads 38,675/s; re-measure or cite one run.

  • Name the goal and the premise of every docs/decisions.md entry. The prose sweep of #48 counted 30 of 58 entries naming no goal and 52 with no "valid while" premise.

  • Make LinkLife start unbound, or its decision's title true. A link attached but not yet bound cannot carry a request, while phase starts up, so the first link and a server session are up before any bind. The LinkLife decision in docs/decisions.md makes the same claim in its title, and carries the same fix. From the comprehension panel of #25.

  • Move checkSessionOptions()'s doc comment to what it describes. It explains why a count below 1 is refused, which is checkLimits' job, and says nothing of the function it heads. From the comprehension panel of #25.

  • Log why DlrMerger.expect() registered no merge. Ids with no common <base>-<n> numbering return silently, the likeliest cause of a messageDlr that never fires and the one that leaves no trace. From the comprehension panel of #25.

  • Make "every README example is executed by the suite" true, or stop claiming it. Goal 10 and the Done table both promise it; test/readme.test.ts transcribes the examples by hand and has drifted — 15 fenced javascript blocks in the README against 10 tests, and the test named "the documented sending options" passes none of the five options the README's example passes. Read the fenced blocks at test time and assert each appears verbatim in the executed source, so an edit to either fails the gate.

  • Narrow the src/codec/ table lint exemption to the four table files. Its stated reason — "the spec tables are data: their length tracks the specification, not any complexity" — is false for codec/types.ts, which is 595 lines of wire codec with 25 functions and is the file that parses hostile input from the network. It carries more over-budget methods than any other file in the repo, under a suppression written for something else.

  • Run the interop suite before cutting a minor, and date the claim. README states "Interoperable. Tested as a client against Jasmin and SMPPSim, and as a server against Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp" in the present tense; interop-tests/README.md is honest that the run was 2026-09-08. Nothing runs the peers on a schedule or before a tag, so the claim rots silently. Goals 1 and 9.

Worth doing, not blocking

  • Move the decisions out of AGENTS.md's fixtures paragraph and the two tooling READMEs. The one dummy SMSC, smscPeer() staying separate and which copied helpers are tolerated (AGENTS.md Conventions), and why Kannel is absent from benchmarks/README.md, go to docs/decisions.md with index lines. Move the planned work written into interop-tests/AGENTS.md (an expected count per peer) and benchmarks/README.md (the default window gap) to this file. Give the run.py-in-background footgun in interop-tests/AGENTS.md a rule of its own. Delete AGENTS.md's "message_id values … are UUID v7" line. From the prose pass of #30.

  • Make the dumbclient soak's memory sample evidence of no library leak again. Its rss ends at its maximum (298 MiB, heapUsed 81 MiB after 173,820 messages), which the harness's own per-id Set and answerOrder explain but cannot separate from a leak in src/: sample the heap after the bookkeeping is cleared. From the stability review of #29.

  • Decide whether alert_notification reaches the application as more than incomingPduObj. It is the SMSC saying a handset it could not reach is reachable again (esme_addr, ms_availability_status); a client has no onRequest, so the raw PDU event is the only way in. Raised by the stability review of #21.

  • Cut the three teardown sentences test/teardown.ts already says. Under AGENTS.md's Conventions, "test/teardown.ts covers a session, a server and a listener" restates its two exported names, "Its close aborts rather than drains" restates closeAfter's own doc comment, and the net.Server.close() sentence restates closeListenerAfter's. Keep the registered-at- creation rule and the FIFO one, which nothing else states, and drop "CI's ten-minute cap" — that number lives in .gitea/workflows/test.yaml. Raised by the prose pass, 2026-09-20.

  • Leave AGENTS.md hard rule 1 the rule, and the decision log its reasoning. Rule 1's fourth sentence — "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" — is the reasoning of the bitCount()/encodeMessage()/splitMessage() entry in docs/decisions.md, which AGENTS.md's own Documentation section makes a defect: it scopes AGENTS.md to an index of the decisions. It also reads two ways — "wherever the types admit one" as an exemption for a typed field, "the check belongs at whichever boundary the argument arrives untyped at" as a requirement at sendSms() — and the message TypeError item sits exactly between them, so one rewrite settles both. Maintainer's call, since it changes what a hard rule asks. From the prose pass of #18.

  • Refuse a delay Node's timers cannot hold, in checkLimits. idleTimeout, reassemblyTimeout, responseTimeout and shutdownTimeout take any integer, and setTimeout fires after 1 ms for anything above 2147483647 — so a value in the wrong unit gets the inverse of what it asked for, explained only by a warning on stderr. connectTimeout refuses one already, which is the asymmetry to close. The same four print an untyped value bare, so idleTimeout: '5000' is refused with got 5000 — a value the reader reads as correct — where connectTimeout quotes it. namedValue()'s four sites — messagingMode, encoding, the time options and smsIdFormat — are the same defect once more: there true and 'true' both print as true. One fix closes all three, and valueText() in codec/types.ts is the quoted spelling to take it from. Raised by review, 2026-09-20.

  • Refuse a send the codec cannot build before it waits for a link and a window slot. Today it waits first. refuse() in outgoing-requests.ts runs misuse() and the abort check before the wait, precisely so a call that can never go out does not queue for what it will never use; a body objToPdu() refuses on every attempt is the same case, and #98 made it a common one. On a down link the caller waits responseTimeout and is told the link failed rather than that the body could not be built — goal 2's wrong answer about what happened. The cheap fix builds the PDU twice, so the shape is the open half. Raised by the architecture review of #98, 2026-09-09.

  • Split the SMPP time format out of message.ts once the file has to move anyway. It answers two questions: message coding and the SMPP time format (smppDate, smppTime) share the file, which the architecture map in AGENTS.md already spells out as four concerns. Nothing is wrong today; if the file has to move for another reason, smpp-time.ts is the split. Raised by the architecture review of #98, 2026-09-09.

  • Add a gate that refuses a floating version anywhere in the repo. Maintainer's ask on #71, 2026-09-06, on the release.yaml pinning thread. Pinning every action and runner by hand is what the ask followed; the gate is what keeps them pinned. It has to cover workflow uses: and runs-on:, compose image:, and Dockerfile FROM, and the conventions differ per kind — actions take a semver tag, images the full patch version — so one grep for latest is not it.

  • Add a gate that catches the test matrix missing the current Node. Maintainer's ask on #71, 2026-09-06, on the Node 26 thread. Node 26 was added by hand; nothing notices when 27 ships. Needs a source for what Current is — the Node release schedule is published as JSON — and a decision on whether a new Current fails the build or opens a PR, which is what Renovate already does for everything else here.

  • Have CodeRabbit review Gitea pull requests through the GitHub mirror. CodeRabbit does not support Gitea, so mirror each Gitea pull request to GitHub for it to review there. Maintainer's ask, 2026-09-14; not started until asked.

  • Count what is left of a budget one way in leftOf() and LinkLife. Today they are one concept counted twice. session/idle-waiters.ts reads what is left of a budget as Math.max(1, deadline - now), because 0 means "forever" there; link-life.ts runs the same subtraction and calls <= 0 expired. Neither is reachable from the other, so nothing can disagree today, but a reader who learns one and applies it to the other is wrong. A budget type both take would close it. Raised by review, 2026-09-01.

  • Write a non-zero err: on a receipt only for a state that failed. receiptText() now writes err:000 for DELIVERED and for the two transient states, and err:001 for every other — so ACCEPTED, SKIPPED, UNKNOWN and DELETED still announce an error code the SMSC never had. Which of those are failures is the open half. Raised by review, 2026-09-03; needs a decision.

  • Share one once() across the test files, one that gives up. Today it is copied into four test files, and two copies never give up. session-extras.test.ts and readme.test.ts reject after 5000 ms; session.test.ts and tls.test.ts wait forever, so an event that never fires still hangs the run the way an unclosed listener used to. One shared, guarded copy closes the rest of that class.

  • Carry the peer's address and bind on every session log message. remoteAddress and remotePort reach only server - incoming connection, and systemId only the bind messages, so with several peers connected one session's lines cannot be told apart, and a reconnect leaves nothing stable to filter on. Carrying them in every session message's metadata is a change to every call site. From #8, closed there as tracked here; maintainer's call, 2026-09-14.

  • Settle how a peer whose message ids share one base logs its refused merges. Today it logs one on every send. smsc01-000123 and smsc01-000124 carry the same base, so DlrMerger merges the first message and refuses every one after it, one log line per send. Left at info — nothing the operator can fix is wrong — but a rate guard or silence may suit it better. Raised by review, 2026-08-30.

  • Exercise submit_multi and the broadcast commands end to end. They encode and decode, but nothing exercises them end to end. The interop suite is the natural place.

  • Move to TypeScript 7 once typescript-eslint supports it. renovate.json pins TypeScript below 6.1 for exactly that reason.

  • Add an onReceipt hook. Receipt text is only loosely specified and operators disagree on it, but dlrFromPdu() is wired into IncomingRequests with no seam of its own: an application facing a format we do not parse has to take the whole PDU on onRequest and reimplement the dispatch, which owns the response as well. Mirror the onRequest seam — return a Dlr to own the receipt, undefined to fall through to the built-in parser.

Gaps against other SMPP libraries

From comparing 0.5.0 with smpp, @semyonf/smpp, @leissner/node-red-smpp, node-smpp-next, smpp-js-sdk, smppjs, cloudhopper-smpp, jsmpp, go-smpp, Kannel, Jasmin and php-smpp, 2026-09-14. Each lands under goal 7: an option or a hook, with the call that passes none unchanged.

Sending

  • Add a limiter hook, with a messages-per-second cap built on it. Maintainer's call, 2026-09-14, reversing the earlier decline: Kannel, Jasmin, go-smpp and smpp-js-sdk all limit throughput. Count PDUs, not sendSms() calls — a long message is one submit_sm per segment and the operator counts those, which an application wrapping sendSms() cannot see. The hook takes a limiter the application already runs; the built-in cap counts per session until the store at the bottom lets it span sessions and processes. The default stays uncapped. Open: the hook's shape (a wait that resolves when a PDU may go, cut short by the send's signal), which requests it gates — messages, never enquire_link, unbind or a response — and whether its wait counts against responseTimeout.

  • Back off and resend on ESME_RTHROTTLED. The SMSC refused the PDU, so resending cannot duplicate it and goal 2 holds, and the retry needs nothing wider than the session. Needs a decision: on by default with a bounded budget, as goal 5 suggests, and whether ESME_RMSGQFUL counts too. A throttled answer is also what a limiter hook wants to hear about.

  • Let sendSms() take the rest of submit_sm. service_type, priority_flag, protocol_id, replace_if_present_flag and TLVs on every segment, and registered_delivery beyond final receipts: on failure only, and intermediate notifications. Today each needs send(), which gives up splitting, the alphabet checks and receipt merging. TLVs are the common case: India's DLT rules put PE_ID (0x1400) and TEMPLATE_ID (0x1401) on every submit_sm, and USSD rides on ussd_service_op. Refuse a TLV the send composes itself (sar_*, message_payload). Open: one spelling for receipts, since dlr: true and a raw registered_delivery could disagree, and whether goal 4's rule on optional parameters binds a TLV the caller named.

  • Choose how a long message is spelled on the wire. Only an 8-bit UDH reference goes out (message.ts), though the reader takes a 16-bit UDH, sar_* and message_payload alike. Some SMSCs take only sar_* or message_payload; php-smpp offers all three. A 16-bit reference also makes a collision rarer: the 8-bit one wraps every 255 sends on a session.

  • Fail over across SMSC hosts. Maintainer's call, 2026-09-14. client() takes one host and port; Kannel, Jasmin and php-smpp take several. A list the reconnect loop walks holds only which host the one session is on, so it needs no store. Two options, both maintainer's calls: - Order, fixed or round robin, default fixed. Fixed starts every reconnect at the first host; round robin at the host after the one the link was last on. - Starting over, a boolean, default on: once the last host has been tried, go back to the first. Off ends the session once the last host fails, as a drop does under reconnect: false. Open: how the list and today's host and port share one spelling; whether the backoff grows per host or per pass; and whether round robin with starting over off still tries the hosts before the one it started at.

The server

  • Send deliver_sm from sendSms() on a server() session. Today it sends submit_sm toward the ESME. Kannel answers ESME_RINVCMDID (interop-tests/kannel.test.ts, "MO to Kannel"), and goal 1 says that PDU never goes out. A server has no other way to send an MO message either: sendMo() in that test builds one from submitSmParams() and ConcatReference, neither exported. Choosing deliver_sm by linkEnd gives MO messages the splitting and checks, keeps one method for one goal, and refuses the options 3.4 has deliver_sm leave empty (scheduleDeliveryTime, validityPeriod).

  • Let a response this library builds carry error TLVs. buildBody() in pdu.ts writes no body for any non-zero status, so a server cannot answer a data_sm with delivery_failure_reason, network_error_code or additional_status_info_text, and a 5.0 peer gets none of its error TLVs. 3.4 omits the body on error for submit_sm_resp by name; read each response's section before widening it. Reading needs nothing: an error response carrying a body already parses.

  • Accept the PROXY protocol on server(). Behind HAProxy or an AWS NLB every session's remote address is the balancer's, so authenticate cannot allow-list by IP and logs name the wrong peer. v1 is text; v2 is binary and the only one an NLB sends. smpp accepts v1 from anyone; accept either only from addresses the option names.

  • Handle outbind. It is in the command table, handled nowhere: a client cannot take an SMSC's outbind and bind back, and server() cannot send one. Rare; take it on with a peer that uses it.

  • Register vendor-specific commands. 3.4 reserves command_id 0x00010200–0x000102FF for SMSC vendors; today one arrives as a PduRefusedError. smpp has addCommand(). The same shape question as registering an encoding.

Encodings

  • Register a custom encoding. Maintainer's ask, 2026-09-14. EncodingName is a closed union of three (codec/encodings.ts). An entry needs a name, a data_coding, encode, decode, match, whether detect() may pick it, and enough for splitMessage() to budget a segment without halving a character. Take encodings as a client or server option rather than mutating a module table as smpp does, so two sessions in one process cannot disagree about a name. A taken name is an err. Settle consts.ENCODING's names first. An entry may claim a data_coding a built-in already uses, maintainer's call, 2026-09-14: an SMSC whose default alphabet, 0x00, is Latin-1 needs Latin-1 written and read under it (#23). The entry then owns that coding on its session both ways: encodingByDataCoding() resolves to it, detect() tries it in the built-in's place, and naming the displaced built-in is an err naming the entry. Two entries on one coding are an err. Settle whether a claim on 0x00 reaches the class groups messageClassEncoding() reads GSM 7-bit from, which flash writes under.

  • Carry the alphabets SMPP 3.4 names that no encoding carries. consts.ENCODING lists the data_coding ids (5.2.19); only ASCII, LATIN1 and UCS2 can be sent. Those with a published definition, and what each costs: - 0x01 IA5 (ITU-T T.50, ASCII in practice): trivial. - 0x06 ISO-8859-5 (Cyrillic) and 0x07 ISO-8859-8 (Hebrew): 96-entry tables. - 0x05 JIS X 0208, 0x0D JIS X 0212, 0x0A ISO-2022-JP and 0x0E KS C 5601: two-octet sets. TextDecoder reads them through ICU — EUC-JP and EUC-KR once each octet's high bit is set, iso-2022-jp as is; checked for JIS X 0208 and KS C 5601 on Node 24.18.0. Nothing built in encodes them, so ship tables or build the reverse map on first use by decoding the 94×94 grid. A Node without full ICU throws from new TextDecoder(), which hard rule 1 wraps into an err. - 0x09 pictogram has no published definition; leave it out.

  • Read and send the GSM 7-bit national language shift tables. 3GPP TS 23.038 defines them for Turkish, Spanish (single shift only), Portuguese and ten Indian languages — Bengali, Gujarati, Hindi, Kannada, Malayalam, Oriya, Punjabi, Tamil, Telugu and Urdu — selected per message by UDH elements 0x25 (locking) and 0x24 (single). They keep that text near GSM's segment size instead of UCS2's 67 characters. Reading means honouring those elements in decodeMessage(); sending means detect() picking a table, with each element's 3 octets off the segment budget. smpp has Turkish, Spanish and Portuguese, used only when the caller writes the UDH.

  • Offer packed GSM 7-bit, opt-in. Everything goes out unpacked, SMPP's convention (AGENTS.md, "GSM 7-bit is sent unpacked"); go-smpp carries a packed codec for SMSCs that want septets. Find an SMSC that needs it before building it.

  • Give each alphabet in consts.ENCODING one name. Five are spelled twice: CYRILLIC/ISO_8859_5, HEBREW/ISO_8859_8, JIS/X_0208_1990, EXTENDED_KANJI_JIS/X_0212_1990 and LATIN1/ISO_8859_1; FLASH is a message class, not an alphabet. One name each before registration starts taking names. A breaking change to an export.

Observability

  • Add metrics: outbound PDU events and counts of requests waiting. Inbound traffic has data, incomingPdu and incomingPduObj; outbound has no event, and nothing counts requests in flight, queued for a window slot, waiting for a link, or unanswered. smpp and @semyonf/smpp emit metrics; cloudhopper keeps per-session counters. An outgoingPdu/outgoingPduObj pair mirrors the inbound events; the counters can be one read-only snapshot, read from the owner of each count rather than a second tally that can drift.

Packaging, tests and CI

  • Ship src, so the source maps lead somewhere. Maintainer's call, 2026-09-14. sourceMap and declarationMap write maps whose sources are ../src/*.ts, but files publishes only dist, so 82 of the package's 167 files, 225 KB of its 555 KB unpacked, point at nothing. Adding src (41 files, 209 KB) makes go to definition land in the TypeScript, and lets a debugger or --enable-source-maps show it.

  • Add a coverage report and a floor to the gate. node --test --experimental-test-coverage --test-coverage-include='src/**' test/*.test.ts on Node 24.18.0, 2026-09-14: 98.77% lines, 93.85% branches, 98.28% functions. Gate at 98, 93 and 98 with --test-coverage-lines, --test-coverage-branches and --test-coverage-functions, which Node 22 and later take — a job of its own on 24, since the matrix runs compiled JavaScript — and add it to main's required checks. Raise the floor as coverage rises; never lower it.

  • Add mutation testing. @stryker-mutator/tap-runner runs node:test suites and measures whether a test notices a change, which coverage cannot; @semyonf/smpp runs Stryker in CI. The session suites are timer-heavy, so start with the codec and the encodings.

  • Build a Node-RED node, as a package of its own. @leissner/node-red-smpp is the only SMPP node in the Node-RED library, and by a read of its source it never parses a receipt and never answers the SMSC's enquire_link. Its UI is a fair list of what operators set. It builds on this package, never inside it.

Declined

  • A check that warns before MIRROR_GITHUB_TOKEN expires. Gitea mails no one about a failed scheduled run, since its Actions bot triggers those, so a nightly check would fail unseen. The maintainer relies on GitHub's own expiry reminders, and on the mirror's first failed push run after expiry, which mails whoever pushed. Maintainer's call, 2026-09-14.

  • CommonJS. ESM only, maintainer's call reaffirmed 2026-09-14, though node-smpp-next ships both. require() of an ES module works unflagged from Node 20.19 and 22.12.

An optional store

  • Add pooling, and state that survives a restart, through an optional store. Maintainer's call, 2026-09-14. It replaces two declines — merge state surviving a restart, and a pool of sessions — and goal 9 was rewritten for it. Big: design before code. - What it holds. Receipts still awaited and the groups DlrMerger collects. Segments of a message already answered but not yet whole, which the peer will not send again (goal 2). The concatenation reference, so a restart does not reuse one. For a pool, the ids every session sent, since an SMSC may deliver a receipt on any bind of the account, and the messages-per-second budget the sessions share. - What it cannot hold. A response belongs to the link its request arrived on, so a message left unanswered at a restart stays unanswerable; the peer's own timeout settles it. - Pooling. Several sessions, in one process or many, behind one send: a message goes to a bound session with a free window slot, all its segments on that one. In one process the in-memory store is enough; across processes the application supplies one. - The interface. Narrow, with keys and records of the library's own making, versioned, with expiry: a record from an older version is read or refused, never misread, and DlrMerger's group shape is never published (goal 8). What processes share needs an atomic operation — compare-and-set or increment — since get-then-set races. - Adapters live elsewhere. Redis, Postgres or SQLite stores are packages of their own; this one ships the interface and the in-memory store, and no runtime dependency (goal 10). - When the store fails. Open: a send whose awaited receipt cannot be recorded is refused, or sent and reported as undetermined (goal 2); a pool whose store is down stops, or falls back to memory. Either way, an application that supplied no store never waits on one. - One spelling. A store-backed cap and the limiter hook both reach a limit shared between processes; settle which owns that case before building the second. - Restore the store operator to README's personas, and drop "which has not shipped" from the Audience bullet, when the store ships. Removed 2026-09-28, maintainer's call.