Decompose the codec, document the session surface and record the review decisions

This commit is contained in:
2026-08-26 22:59:39 +02:00
parent 6d842a8539
commit 435fa42708
10 changed files with 186 additions and 96 deletions
+14
View File
@@ -154,6 +154,20 @@ exactly 140.
## Decisions
- **The published surface is frozen at what `src/index.ts` exports today.** `Session` is exported and
publicly constructible, which is why `SessionOptions` and `ReconnectOptions` are public too — that
is correct, not a leak, and it has been raised twice. The collaborators `session.ts` delegates to
(`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `DlrMerger`,
`submitSms`) stay unpublished so they can be reshaped.
- **The sub-3.4 optional-parameter rule is a predicate, not a chokepoint.** `acceptsOptionalParams()`
is consulted by the library's own senders; `session.send({ tlvs })` is passed through as written,
because silently stripping a caller's explicit TLVs off a deliberately public low-level surface
would be worse than sending them. The guarantee is "what this library sends honours the rule",
never "the session cannot send optional parameters to an old peer".
- **Only the server feeds `peerInterfaceVersion`.** `acceptBind()` records what the peer declared;
the client never reads `sc_interface_version` out of its bind response, so a client session is
permissive. That is not a defect today — this library's ESME direction sends no TLVs at all — but
anyone adding a client-side TLV owes the other half of the feed.
- **The library speaks SMPP 3.4 on the wire, and `defs/` keeps the 5.0 tables as a superset.**
Maintainer's call, 2026-08-26: 3.4 is what SMSCs actually run, while the wider tables let the codec
parse and build whatever a peer sends. The declared version is an option on both `client()` and
+6 -1
View File
@@ -171,7 +171,7 @@ await smpp.close(); // stop listening and close every live session
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
| `reassemblyTimeout` | `300000` | How long an incomplete multipart message is held. |
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
| `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
## Errors
@@ -218,6 +218,11 @@ const { err, pduObj } = await session.send({
});
```
`acceptsOptionalParams()` answers whether the peer declared SMPP 3.4 or later, which is the version
at and above which the spec allows optional parameters to be sent to it; `peerInterfaceVersion` is
the raw value it declared. The library's own senders consult the first before attaching a TLV — a
`send()` you build yourself is passed through as written, so consult it too when you attach TLVs.
## Working with PDUs directly
The codec is exported, synchronous, and never throws — handy for inspecting captured traffic:
+3 -7
View File
@@ -39,13 +39,9 @@ export default tseslint.config(
rules: { 'max-lines': 'off' },
},
{
// The codec branches per wire type and per parameter; splitting it scatters the wire format
// across files instead. These two keep the ceiling they have today.
files: ['src/dlr.ts', 'src/pdu.ts'],
rules: {
complexity: ['error', 22],
'max-lines-per-function': ['error', { max: 75, skipBlankLines: true, skipComments: true }],
},
// ESLint counts every ?. and ?? in dlrFromPdu as a branch; the 19 is 26 lines of flat field resolution.
files: ['src/dlr.ts'],
rules: { complexity: ['error', 19] },
},
{
// ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns.
+3 -2
View File
@@ -120,6 +120,7 @@ export function parseReceipt(message: string): Receipt {
export function dlrFromPdu(pduObj: PduObject): Dlr | undefined {
const message = pduObj.params.short_message;
const receipt = typeof message === 'string' ? parseReceipt(message) : undefined;
const receiptState = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
const tlvState = pduObj.tlvs.message_state?.tagValue;
const tlvId = pduObj.tlvs.receipted_message_id?.tagValue;
@@ -132,13 +133,13 @@ export function dlrFromPdu(pduObj: PduObject): Dlr | undefined {
const statusMsg = typeof tlvState === 'number'
? constsById.MESSAGE_STATE?.[tlvState]
: receiptStates[receipt?.stat?.toUpperCase() ?? ''];
: receiptState;
if (statusMsg === undefined) return undefined;
const statusId = typeof tlvState === 'number'
? tlvState
: consts.MESSAGE_STATE[receiptStates[receipt?.stat?.toUpperCase() ?? ''] ?? 'UNKNOWN'];
: consts.MESSAGE_STATE[receiptState ?? 'UNKNOWN'];
return {
doneDate: receiptDate(receipt?.doneDate),
+120 -68
View File
@@ -1,7 +1,7 @@
import type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
import type { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.ts';
import type { Result } from './result.ts';
import type { Result, VoidResult } from './result.ts';
import type { Tlv } from './defs/tlvs.ts';
import { cmds, commandNameById, isCommandName } from './defs/commands.ts';
import { consts } from './defs/constants.ts';
@@ -79,46 +79,31 @@ function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
return { tagId };
}
function buildPdu(
cmdName: CommandName,
cmdStatus: ErrorName,
seqNr: number,
/** Encoding a string short_message also settles data_coding and sm_length. */
function resolveShortMessage(
params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
): Result<{ buffer: Buffer }> {
const definition = cmds[cmdName];
): Record<string, ParamValue | undefined> {
const message = params.short_message;
if (!definition) {
return { err: new Error(`Invalid cmdName: ${JSON.stringify(cmdName)}`) };
}
if (typeof message !== 'string') return { ...params };
if (!isErrorName(cmdStatus)) {
return { err: new Error(`Invalid cmdStatus: ${JSON.stringify(cmdStatus)}`) };
}
const dataCoding = params.data_coding;
const encoding = typeof dataCoding === 'number' ? encodingByDataCoding(dataCoding) : detect(message);
const encoded = encodeMessage(message, encoding);
if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxSeqNr) {
return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) };
}
const resolved = { ...params };
const message = resolved.short_message;
// A string short_message is encoded here, which also settles data_coding and sm_length.
if (typeof message === 'string') {
const dataCoding = resolved.data_coding;
const encoding = typeof dataCoding === 'number'
? encodingByDataCoding(dataCoding)
: detect(message);
const encoded = encodeMessage(message, encoding);
resolved.short_message = encoded.buffer;
resolved.sm_length = encoded.buffer.length;
if (typeof dataCoding !== 'number') {
resolved.data_coding = consts.ENCODING[encoded.encoding];
}
}
return {
...params,
data_coding: typeof dataCoding === 'number' ? dataCoding : consts.ENCODING[encoded.encoding],
short_message: encoded.buffer,
sm_length: encoded.buffer.length,
};
}
function writeParams(
definition: CommandDefinition,
resolved: Record<string, ParamValue | undefined>,
cmdName: CommandName,
): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = [];
for (const [name, type] of Object.entries(definition.params ?? {})) {
@@ -139,6 +124,12 @@ function buildPdu(
chunks.push(chunk);
}
return { chunks };
}
function writeTlvs(tlvs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = [];
for (const [name, tlv] of Object.entries(tlvs ?? {})) {
const tag = tagIdOf(name, tlv);
@@ -169,7 +160,39 @@ function buildPdu(
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
return { chunks };
}
function buildPdu(
cmdName: CommandName,
cmdStatus: ErrorName,
seqNr: number,
params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined,
): Result<{ buffer: Buffer }> {
const definition = cmds[cmdName];
if (!definition) {
return { err: new Error(`Invalid cmdName: ${JSON.stringify(cmdName)}`) };
}
if (!isErrorName(cmdStatus)) {
return { err: new Error(`Invalid cmdStatus: ${JSON.stringify(cmdStatus)}`) };
}
if (!Number.isInteger(seqNr) || seqNr < 0 || seqNr > maxSeqNr) {
return { err: new Error(`Invalid seqNr: ${JSON.stringify(seqNr)}`) };
}
const written = writeParams(definition, resolveShortMessage(params), cmdName);
if (written.err) return { err: written.err };
const writtenTlvs = writeTlvs(tlvs);
if (writtenTlvs.err) return { err: writtenTlvs.err };
const body = Buffer.concat([...written.chunks, ...writtenTlvs.chunks]);
const header = Buffer.alloc(16);
header.writeUInt32BE(body.length + 16, 0);
@@ -223,22 +246,11 @@ function parseTlvs(
return { offset, tlvs };
}
function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolean; pduObj: PduObject }> {
const cmdLength = pdu.readUInt32BE(0);
const cmdId = pdu.readUInt32BE(4);
const cmdName = commandNameById(cmdId);
if (!cmdName) {
return { err: new Error(`Unknown PDU command id: ${String(cmdId)}`) };
}
const cmdStatusId = pdu.readUInt32BE(8);
const seqNr = pdu.readUInt32BE(12);
if (seqNr > maxSeqNr) {
return { err: new Error(`Invalid seqNr, exceeds ${String(maxSeqNr)}: ${String(seqNr)}`) };
}
function readParams(
cmdName: CommandName,
pdu: Buffer,
trailingNull: boolean,
): Result<{ offset: number; params: Record<string, ParamValue> }> {
const params: Record<string, ParamValue> = {};
let offset = 16;
@@ -255,10 +267,34 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
if (name === 'short_message' && trailingNull) offset++;
}
const parsed = parseTlvs(pdu, offset, cmdLength);
return { offset, params };
}
function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolean; pduObj: PduObject }> {
const cmdLength = pdu.readUInt32BE(0);
const cmdId = pdu.readUInt32BE(4);
const cmdName = commandNameById(cmdId);
if (!cmdName) {
return { err: new Error(`Unknown PDU command id: ${String(cmdId)}`) };
}
const cmdStatusId = pdu.readUInt32BE(8);
const seqNr = pdu.readUInt32BE(12);
if (seqNr > maxSeqNr) {
return { err: new Error(`Invalid seqNr, exceeds ${String(maxSeqNr)}: ${String(seqNr)}`) };
}
const read = readParams(cmdName, pdu, trailingNull);
if (read.err) return { err: read.err };
const parsed = parseTlvs(pdu, read.offset, cmdLength);
if (parsed.err) return { err: parsed.err };
const params = read.params;
const message = params.short_message;
const esmClass = numberOr(params.esm_class, 0);
@@ -282,7 +318,7 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
};
}
export function pduToObj(pdu: Buffer): Result<{ pduObj: PduObject }> {
function checkFraming(pdu: Buffer): VoidResult {
if (pdu.length < 16) {
return { err: new Error(`PDU is too short, minimum is 16 octets, got ${String(pdu.length)}`) };
}
@@ -297,6 +333,14 @@ export function pduToObj(pdu: Buffer): Result<{ pduObj: PduObject }> {
return { err: new Error(`cmd_length ${String(cmdLength)} exceeds the ${String(pdu.length)} octets given`) };
}
return {};
}
export function pduToObj(pdu: Buffer): Result<{ pduObj: PduObject }> {
const framing = checkFraming(pdu);
if (framing.err) return { err: framing.err };
const plain = parseOnce(pdu, false);
if (!plain.err && plain.aligned) return { pduObj: plain.pduObj };
@@ -311,6 +355,25 @@ export function pduToObj(pdu: Buffer): Result<{ pduObj: PduObject }> {
return { err: plain.err };
}
/** Fields the response shares with the request are echoed back unless the caller overrode them. */
function echoParams(
respName: CommandName,
pdu: PduObject,
params: Record<string, ParamValue>,
): Record<string, ParamValue> {
const respParams: Record<string, ParamValue> = { ...params };
for (const name of Object.keys(cmds[respName]?.params ?? {})) {
const value = pdu.params[name];
if (respParams[name] === undefined && value !== undefined) {
respParams[name] = value;
}
}
return respParams;
}
export function pduReturn(
pdu: Buffer | PduObject,
status: ErrorName = 'ESME_ROK',
@@ -329,16 +392,5 @@ export function pduReturn(
return { err: new Error(`"${pdu.cmdName}" has no response command`) };
}
const respParams: Record<string, ParamValue> = { ...params };
// Fields the response shares with the request are echoed back unless the caller overrode them.
for (const name of Object.keys(cmds[respName]?.params ?? {})) {
const value = pdu.params[name];
if (respParams[name] === undefined && value !== undefined) {
respParams[name] = value;
}
}
return buildPdu(respName, status, pdu.seqNr, respParams, tlvs);
return buildPdu(respName, status, pdu.seqNr, echoParams(respName, pdu, params), tlvs);
}
+2 -2
View File
@@ -4,7 +4,7 @@ import type { Result } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls';
import { EventEmitter } from 'node:events';
import { Session, bindCommands } from './session.ts';
import { Session, bindCommands, defaultSystemId } from './session.ts';
import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -45,7 +45,7 @@ const defaults = {
idleTimeout: 40_000,
interfaceVersion: defaultInterfaceVersion,
port: 2775,
systemId: '',
systemId: defaultSystemId,
};
/** A listening SMPP server. Sessions arrive as `session` events; `close()` stops listening. */
+3 -1
View File
@@ -72,6 +72,8 @@ export type SessionOptions = {
systemId?: string | undefined;
};
export const defaultSystemId = '';
const defaults = {
maxDelay: 30_000,
maxOutstanding: 10,
@@ -79,7 +81,7 @@ const defaults = {
minDelay: 1000,
reassemblyTimeout: 300_000,
responseTimeout: 30_000,
systemId: '',
systemId: defaultSystemId,
};
export const bindCommands: readonly string[] = [
+16 -13
View File
@@ -84,20 +84,23 @@ describe('dlrFromPdu()', () => {
assert.equal(dlr.doneDate?.toISOString(), '2025-08-25T14:31:00.000Z');
});
test('maps every spec status code back to its message state', () => {
for (const [code, expected] of [
['DELIVRD', 'DELIVERED'],
['UNDELIV', 'UNDELIVERABLE'],
['EXPIRED', 'EXPIRED'],
['DELETED', 'DELETED'],
['ACCEPTD', 'ACCEPTED'],
['REJECTD', 'REJECTED'],
['ENROUTE', 'ENROUTE'],
['UNKNOWN', 'UNKNOWN'],
]) {
const dlr = dlrFromPdu(deliverSm(`id:x stat:${String(code)} err:0`));
test('maps every spec status code back to its message state and id', () => {
for (const [code, expected, statusId] of [
['DELIVRD', 'DELIVERED', 2],
['UNDELIV', 'UNDELIVERABLE', 5],
['EXPIRED', 'EXPIRED', 3],
['DELETED', 'DELETED', 4],
['ACCEPTD', 'ACCEPTED', 6],
['REJECTD', 'REJECTED', 8],
['ENROUTE', 'ENROUTE', 1],
['UNKNOWN', 'UNKNOWN', 7],
['delivrd', 'DELIVERED', 2],
] as const) {
const dlr = dlrFromPdu(deliverSm(`id:x stat:${code} err:0`));
assert.equal(dlr?.statusMsg, expected);
assert.ok(dlr);
assert.equal(dlr.statusMsg, expected);
assert.equal(dlr.statusId, statusId);
}
});
+15
View File
@@ -120,6 +120,21 @@ describe('encoding submit_sm', () => {
);
});
test('encodes a string short_message with the given data_coding instead of a detected one', () => {
const pdu = encode({
cmdName: 'submit_sm',
params: {
data_coding: 0x08,
destination_addr: '46709771337',
short_message: 'hi',
source_addr: '46701113311',
},
seqNr: 12,
});
assert.equal(pdu.subarray(-7).toString('hex'), '08000400680069');
});
test('keeps a UDH-carrying short_message as a buffer', () => {
const message = Buffer.concat([
Buffer.from('050003010101', 'hex'),
+4 -2
View File
@@ -77,8 +77,10 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
- [ ] **`session.ts` is 459 lines.** The one seam left in it is a socket-to-PDU transport, which
would move the deliberately public `sock` field out of `Session` or turn it into a getter —
a public-surface change, so it waits for a decision.
- [ ] **`buildPdu` and `dlrFromPdu` carry a complexity of 22.** `eslint.config.js` holds them at
that ceiling rather than below the repo-wide 10, so neither can grow but neither shrinks.
- [ ] **Group the session's collaborators under `src/session/`.** Only `session.ts` imports
`reassembly`, `dlr-merger`, `send-window`, `link-timers`, `reconnect-loop`, `pending-requests`
and `send-sms`, so the directory would make that boundary visible. Do it on the next
extraction out of `session.ts`, not as a move of its own.
- [ ] **`submit_multi` and the broadcast commands** 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