22 KiB
Plan 1: the application developer's mental model as the source tree
Lens: an application developer thinks in six verbs: connect (client), listen (server), send, receive, get a report, shut down, plus read PDUs when they go low-level. Every directory is one of those verbs, in the README's order, and every boundary between them is a seam a developer already knows exists.
1. Principles
- The README's table of contents is the directory listing.
Send an SMS→sending/,Delivery reports→receipts/,Receive SMS→receiving/,Run an SMPP server→server/,Client options/reconnect →client/,Session→session/,PDUs and the low-level API→wire/,Encoding/long messages →text/. Answers: Navigation stuck at 6 in every round, while C, the one draft with grouped folders, reached Locality 6 on every seat. - One socket, one
Session, one life. ASessiongoesopen → bound → closing → closedand never backwards. Reconnect is a separateClientSessionabove it, whose state is a discriminated union holding aSessiononly while one is up. Answers lesson 3:LinkLife's phase plusstopped, seven predicates, call-order-dependentlinkLost/end/dropSocket, andReconnectLoop's duplicate stopped flag. F showed that the split removes the unit; this plan keeps F's split and fixes what F left, below. - Every invariant has one owner, and that owner's file states it. "Every inbound request gets
exactly one answer" →
session/owed-answer.ts. "Nothing held without a bound" →limits/bounded-store.ts. "A send that never reached a socket may go out on the next one" →client/next-link.ts. Answers round three's "enforced jointly byIncomingRequestsandsms.ts", and D's "answered in three places". - Lower areas never call up; they declare the port they need.
receiving/,receipts/andsending/export functions and classes that take a narrow port type declared in their own file (AnswerPort,SendPort,report(err)).Sessionimplements the ports. Answers finding 4:IncomingRequests/HeldMessagescallingemit,sendReturn,closeandlistenerCountonSession. - A store enforces its own bound.
BoundedStoreevicts, expires and weighs by itself, never evicts the entry being admitted, keeps reads pure, and reports every removal through oneonRemoved(key, value, reason). Answers round three's most-cited unit:ExpiringGroupsplusReassembler.trim. - A state change's effects sit inside the transition that causes them. No reducer that returns effects, and no callbacks wired from another file. Answers E, whose state machine scored no better because each transition's meaning was split over five callbacks.
- Every SMPP term is glossed once, and every spec citation carries its half-line summary.
SMPP 3.4 5.2.12 (esm_class: message type and mode bits), never a bare5.2.12. The README gets a glossary table (ESME, SMSC/MC, PDU, bind, esm_class, data_coding, UDH, sar_*, TLV, receipt, segment). Answers lesson: juniors score Self-sufficiency 5 because of bare citations anddata_codingbit masks. - Every default in one file.
src/defaults.ts, grouped by the verb that uses them. Answers "defaults spread over several files". - Hard parts are marked by one convention. A hard part opens with an
Invariant:paragraph at the code it guards, which the comment rules permit, and AGENTS.md gets a "Where it is hard" list naming the file of each. The panel's "7" asks for hard parts that are few, localized and marked.
2. Layout
Dependencies point downward in this order: wire ← text ← limits ← {sending, receiving,
receipts} ← session ← {client, server} ← index.ts. Root files are importable by all.
The only ways up are ports: types declared low, implemented by session/.
src/ fan-out 13: 4 files, 9 areas
index.ts Public surface, named exports only. Grouped by the README's sections.
defaults.ts Every default value, grouped by client/server/session/stores. No logic.
log.ts SmppLog, silentLog, guardedLog.
result.ts Result<T>, VoidResult, errorFrom(), namedValue(), UnansweredError.
wire/ "PDUs and the low-level API". Fan-out 6 + tables/
pdu.ts pduToObj/objToPdu/pduReturn/isCommand/isResp. Stateless, total.
framer.ts PduFramer: byte stream → complete PDUs. State: the partial buffer.
refusal.ts PduRefusedError, PduHeader, the status SMPP names for each refusal.
retained-pdu.ts detach() and retainedOctets(): what holding a PDU costs.
smpp-time.ts smppDate/smppTime: the 16-char time format, absolute and relative.
tables/ The spec, as data. Fan-out 7. Knows nothing above it but result.ts.
commands.ts 33 commands, ids, params in WIRE ORDER (invariant stated at the top).
constants.ts consts, constsById, interface versions.
alphabets.ts GSM 03.38 (named gsm7 internally; 'ASCII' only as the public name), LATIN1, UCS2 codecs; detection.
data-coding.ts data_coding → alphabet and message class; one row per coding group with its meaning in words.
errors.ts ESME_* by numeric id.
tlvs.ts TLV table by numeric id; typed read/write of a TLV stream.
types.ts Wire types int8…cstring, arrays.
index.ts defs, the grouped tables.
text/ "Encoding" and "Long messages". Fan-out 3. Pure functions.
message-text.ts encode/decode/bitCount/unencodable; where a PDU's body is (short_message vs message_payload).
splitting.ts splitMessage and segment budgets (153 GSM unpacked / 134 / 67); the "GSM is sent unpacked" note lives here.
concatenation.ts Both spellings, both directions: UDH build/parse, sar_* read, concatOf(), ConcatReference counter.
limits/ What is bounded, and waiting on it. Fan-out 3.
bounded-store.ts BoundedStore<V>: count cap, weight cap, expiry, one sweep timer, onRemoved(key, v, reason). Invariant: admit never evicts its own key; get() never mutates.
send-window.ts SendWindow: the maxOutstanding semaphore; idle(timeout) for the drain.
idle-waiters.ts Waiting for a count to reach zero within a budget.
sending/ "Send an SMS". Fan-out 2.
compose-submit.ts submit_sm params from SendSmsOptions: TON, messaging mode, flash, times, the alphabet check. Pure.
sms-sender.ts SmsSender: split, send every segment together through a SendPort, collect ids, tell the merger. State: ConcatReference, ReceiptMerger.
receipts/ "Delivery reports". Fan-out 5.
receipt-states.ts message_state ↔ stat: codes, FAILED/DELIVERD aliases, which states are transient. Invariant: only ENROUTE/SCHEDULED are not final.
read-receipt.ts dlrFromPdu(), parseReceipt(): esm_class, then TLV, then body.
write-receipt.ts The deliver_sm a sendDlr() sends: text, TLVs, esm_class 0x04 vs 0x20. Pure.
receipt-merger.ts ReceiptMerger: per-segment receipts into one MessageDlr. State: open groups, spent bases (BoundedStore x2). `close` renamed `spend`.
message-ids.ts uuidv7(), <base>-<n>, smsIdFormat normalisation, which response carries an id.
receiving/ "Receive SMS" and "Receiving in depth". Fan-out 4.
receive-message.ts The one path an inbound message takes: bound check → concat? → reassemble → answer segment → hand whole message to handlers. Takes AnswerPort per request.
reassembler.ts Reassembler: incomplete groups in a BoundedStore; lost groups reported once.
running-handlers.ts RunningHandlers: onSms calls in flight. State: count, weight, deadline per call. Invariant: return releases, throw refuses with the retry status, no handler refuses at once. Its SendPort lets a running handler's sendDlr pass the drain.
sms.ts The Sms handle: fields decoded once; sendResp → the message's OwedAnswers; sendDlr → write-receipt via SendPort.
session/ "Session". One socket's life. Fan-out 8.
session.ts Session (public): state 'open'|'bound'|'closing'|'closed' in one field; bound(), send, sendSms, sendReturn, close, unbind; emits. Each transition method carries its own effects.
session-options.ts SessionOptions type and its checks.
transport.ts One socket + framer + write(). No attach(): a socket never changes.
heartbeat.ts enquire_link on quiet, idle timeout. State: two timers.
requests.ts Outgoing: seqNr, pending map, response timeout, window slot, abort, UnansweredError. Merges pending-requests + outgoing-requests minus link logic.
dispatch.ts An inbound PDU's route: response → requests; request → onRequest hook → bind direction → enquire_link/unbind/re-bind/unknown/message/receipt.
owed-answer.ts OwedAnswer: created for every inbound request at dispatch; the only writer of a response. State: owed|answered|lost. Implements AnswerPort.
bind-direction.ts What each bind type carries per link end; data_sm stands in for submit_sm or deliver_sm; checkedBind().
shutdown.ts The drain: handlers first, then requests, one deadline, the err naming what was lost.
client/ "Client options" and reconnect. Fan-out 5.
client.ts client(), ClientSession (public): state {kind:'connecting'} | {kind:'up', session} | {kind:'down', attempt} | {kind:'closed'}. Forwards events of the current Session; owns SmsSender so merges survive a reconnect.
client-options.ts ClientOptions and their checks (connectTimeout, reconnect spelling, fromStart).
connect.ts Open a socket: TCP or TLS, connectTimeout over both.
bind.ts bind_* params, the bind response → session.bound().
reconnect.ts Backoff as a pure function (delay, upSince) → next delay, and the retry timer. No stopped flag: the ClientSession's 'closed' state is the stop.
next-link.ts Sends waiting for a bound Session, one budget each; a send that never reached a socket retries here. Invariant: nothing that reached a socket is resent.
server/ "Run an SMPP server" and "Server in depth". Fan-out 3.
server.ts server(), SmppServer: listener, sessions set, close() = stop listening then drain each.
server-options.ts ServerOptions and their checks.
accept-bind.ts Pre-bind requests, authenticate, bind_resp with sc_interface_version, Session.bound().
Deepest level is 3 (src/wire/tables/). Maximum fan-out 13 at src/, otherwise ≤ 8.
3. Public API changes
Three changes; everything else in the README keeps its spelling, including client() resolving
{ err, session }, so every send and receipt example survives untouched.
- Inbound messages reach an
onSmshandler option instead of ansmsevent.- Old:
session.on('sms', async sms => { await sms.sendResp(); }), and a server'ssmpp.on('session', s => s.on('sms', …)). - New:
client({ onSms }),server({ onSms }),new Session({ onSms }), typed(sms: Sms) => Promise<void> | void.sms.sendResp()stays the only way to answer, with the same options. Returning releases the message, answeringESME_ROKfirst ifsendResp()was not called. Throwing or rejecting refuses it with the retry status (ESME_RTHROTTLEDorESME_RX_T_APPN) unless it is already answered, and reports it onsessionError. With noonSms, every message is refused at once with that retry status and onewarnis logged.sendDlr()before the answer returnserr.sms.answeredOnArrivalstays. - Removes: the held-message timing contract (lesson 1): listener counts, the
setImmediateturn,captureRejectionsrouted through aWeakMap, and "answered" living in three places, becauseansweredOnArrivalbecomes a getter over the OwedAnswers. - Serves goal 2 (a crash before the answer leaves the peer to resend, unlike C) and goal 5. The no-handler refusal is a judgement call that goes in docs/decisions.md, resting on goal 2's "work the peer has no reason to send again is not dropped".
- Migration is one line per listener: the handler body is unchanged.
- Old:
- A
Sessionis one socket; reconnect isClientSession, whichclient()returns.- Old:
Sessionwith areconnect: { connect, onConnected }option,disconnectedandreconnectedevents, andsockswapped under it. - New:
Sessionhas noreconnectoption, nodisconnected/reconnected, and a fixedsock.client()still resolves{ err, session }, wheresessionis aClientSessionwith today's client methods (sendSms,send,unbind,close,boundAs,peerInterfaceVersion,acceptsOptionalParams(),bindAllows()) and events (close,disconnected,reconnected,dlr,messageDlr,sessionError, plusdata/incomingPdu/incomingPduObjforwarded from the current link).session.sockandsession.sendReturn()move tosession.link, the currentSessionorundefinedwhile down, because both belong to one socket. A hand-wired ESME that wants reconnect builds a newSessionper socket. - Removes:
LinkLifeand its predicates, the call-order hazard,ReconnectLoop's duplicate stop, and client.ts'sbindOndepending on another file's ordering (lesson 3). - Serves goal 8 (a smaller surface, a stated scope for
Session) and goal 4 (one stop state, so no path rebinds after close).
- Old:
linkEndbecomes a readonly constructor option onSession(old: a writable fieldsession.linkEnd = 'smsc'). A field mutable after dispatch starts is a hidden state a reader has to chase throughbind-direction.ts. Serves goal 4, since the bind direction decides what is refused.
Kept deliberately: encoding: 'ASCII' as the public name of GSM 03.38 (inherited from 0.4.0,
MIGRATION.md relies on it). Internally the alphabet is gsm7 everywhere, and alphabets.ts glosses
the public name once. sessionError's kinds stay told apart by type and message: no panel cited them.
4. Where each thing goes
| Now | New home |
|---|---|
| client.ts | client/client.ts (client(), fromStart), client/connect.ts (openSocket, connectTimeout), client/bind.ts |
| server.ts | server/server.ts, server/accept-bind.ts (authenticate, pre-bind, bind_resp) |
| session.ts | session/session.ts (state, API, emit guard); drain → session/shutdown.ts; dispatch/refuse → session/dispatch.ts |
| sms.ts | receiving/sms.ts (handle, sendResp); receipt building → receipts/write-receipt.ts |
| concat.ts, udh.ts | text/concatenation.ts |
| dlr.ts | receipts/read-receipt.ts, receipts/receipt-states.ts |
| dlr-merger.ts | receipts/receipt-merger.ts (close → spend) |
| error-from.ts, unanswered-error.ts, result.ts | result.ts |
| expiring-groups.ts | limits/bounded-store.ts (enforcing its own caps) |
| held-messages.ts | receiving/running-handlers.ts |
| idle-waiters.ts, send-window.ts | limits/ |
| incoming-requests.ts | session/dispatch.ts (routing, bind direction, unbind, unknown) + receiving/receive-message.ts (message path, store bound) |
| link-life.ts | deleted: the state goes to session.ts's one field; waiting for a link goes to client/next-link.ts |
| link-timers.ts | session/heartbeat.ts |
| log.ts, result.ts | root |
| message.ts | text/message-text.ts, text/splitting.ts; smppDate/smppTime → wire/smpp-time.ts |
| message-body.ts | text/message-text.ts |
| outgoing-requests.ts, pending-requests.ts | session/requests.ts; the next-link retry → client/next-link.ts |
| pdu.ts, pdu-framer.ts, pdu-refusal.ts, retained-pdu.ts | wire/ |
| pdu-transport.ts | session/transport.ts, without attach() |
| reassembly.ts | receiving/reassembler.ts; decodeSegments → text/message-text.ts |
| reconnect-loop.ts | client/reconnect.ts |
| send-sms.ts | sending/compose-submit.ts + sending/sms-sender.ts |
| session-options.ts | defaults → defaults.ts; checks → each area's *-options.ts; bindCarries/standsInFor/checkedBind → session/bind-direction.ts |
| sms-id.ts, uuid.ts | receipts/message-ids.ts |
| defs/* | wire/tables/*; encodings.ts split into alphabets.ts and data-coding.ts |
Named-hard responsibilities:
| Responsibility | Home and owner |
|---|---|
| Held messages and answering | session/owed-answer.ts (one answer per request); receiving/running-handlers.ts (the handler's life) |
| Lifecycle and reconnect | session/session.ts (one-way state); client/client.ts (union state), client/reconnect.ts (backoff) |
| The drain | session/shutdown.ts, one function; a running handler's sends admitted through its own SendPort |
| Outgoing requests and retry | session/requests.ts (one link, no retry); client/next-link.ts (the only retry) |
| Reassembly and ExpiringGroups | receiving/reassembler.ts over limits/bounded-store.ts |
| Receipts and merging | receipts/ (read, states, write, merger); merger owned by SmsSender, which ClientSession holds across links |
| The codec | wire/pdu.ts over wire/tables/ |
| Encodings | wire/tables/alphabets.ts, wire/tables/data-coding.ts; text/ above them |
| Defaults | src/defaults.ts |
| Domain knowledge and glossary | README glossary table; summarised citations; the defect table and "GSM is sent unpacked" stay in AGENTS.md, with a pointer line in splitting.ts |
5. The hard parts that stay hard
Each is marked with an Invariant: paragraph in its file and listed under "Where it is hard" in
AGENTS.md.
- Exactly one answer per inbound request (session/owed-answer.ts). The hardness is SMPP's: a
multipart message is answered per segment on arrival, a single one when the application says, a
refused PDU from its header alone, and never on a link that is gone. Localized, since OwedAnswer
is the only writer, and a test asserts no other file calls
transport.writewith a response. - The drain's order and budgets (session/shutdown.ts). Handlers first, because a handler's
answer can put a receipt on the wire.
shutdownTimeout: 0still bounds the handler half. One function, about 40 lines, with its budget rule in its signature. - Retry only what never reached the socket (client/next-link.ts). Goal 2's "never re-sent on
the library's own initiative". The rule is one predicate over the
requests.tsresult (written: false), and the loop lives only here. - Bounded stores and eviction order (limits/bounded-store.ts). The reassembly weight rule, 1000 plus octets plus 300 per TLV, stays in receiving/reassembler.ts as one function beside its README-facing constant.
- data_coding coding groups (wire/tables/data-coding.ts). Bit masks are unavoidable. One table row per group states in words what the group means and whether it carries a class.
- Receipt classification (receipts/read-receipt.ts). esm_class, then TLV, then text, with operator aliases. It is operator folklore, so each alias names the operator in one line.
6. Build order
Each chunk keeps the suite green. Tests move to the new API only in chunks 5 and 6, the two contract changes.
- Tables and codec. Move defs/ to wire/tables/, split encodings, rename gsm7 internally, and move pdu, framer, refusal, retained and time into wire/. Add summaries to every citation.
- text/ and receipts/. Pure moves plus
spend. message-ids absorbs uuid. - limits/. BoundedStore replaces ExpiringGroups, with its own tests for "admit never evicts itself" and "reads are pure". Reassembler, merger and held messages port onto it.
- *defaults.ts, the -options.ts files, sending/. SmsSender with its SendPort.
- Contract 1: onSms. Add owed-answer.ts, receiving/, and session/dispatch.ts. Delete
held-messages and incoming-requests. Port the
smslisteners in tests and README, and add the decision record for the no-handler refusal. - Contract 2: one-socket Session, ClientSession. Session gets its one-way state, plus
transport without attach, requests, heartbeat and shutdown. The client gets client/,
next-link and reconnect. Delete link-life. Port the reconnect tests to ClientSession, and make
linkEndan option. - server/ split, index.ts regrouped by README section, the README glossary, AGENTS.md architecture, and "Where it is hard".
- Run the comprehension panel. Fix only what it names inside the hard-parts list.
7. Predicted panel risks
- ClientSession forwarding (client/client.ts). It re-emits the current Session's events and
answers
boundAsthrough the gap. A reader asks "which object do I listen on?" Mitigation: README Events table gains one column,Session/ClientSession. Likely Shape 6 on the senior's seat if the forwarding list is long. - Two SmsSenders in client mode. A Session inside a ClientSession has its own unused sender, and
the ClientSession's merger is fed from the link's
dlr. Alternative: Session takes an optional injected SmsSender. Pick one at chunk 6 and state it in client.ts's invariant. - Ports feel like indirection to the junior.
AnswerPort/SendPortadd names. Mitigation: each port is 1–3 members and declared in the file that uses it. - Root fan-out of 13, and
limits/is a new abstraction name. A mid may look for the send window under session/. It is cross-referenced from requests.ts's constructor argument only. - The no-handler refusal surprises a transceiver client that never expected MO traffic: its SMSC retries forever. That is a goal-2-correct outcome, but a reader may argue it; the decision record must carry the argument.
- The coarse scale. Removing a unit has moved the hardest unit elsewhere every round. The most
likely next candidate is
owed-answer.ts+receive-message.ts, which could hold a mean at 6.5 rather than 7. The mitigation is to keep it the only writer and test that.