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 ## 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.** - **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 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 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. | | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. | | `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. | | `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 | | | `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
## Errors ## 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 ## Working with PDUs directly
The codec is exported, synchronous, and never throws — handy for inspecting captured traffic: 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' }, rules: { 'max-lines': 'off' },
}, },
{ {
// The codec branches per wire type and per parameter; splitting it scatters the wire format // ESLint counts every ?. and ?? in dlrFromPdu as a branch; the 19 is 26 lines of flat field resolution.
// across files instead. These two keep the ceiling they have today. files: ['src/dlr.ts'],
files: ['src/dlr.ts', 'src/pdu.ts'], rules: { complexity: ['error', 19] },
rules: {
complexity: ['error', 22],
'max-lines-per-function': ['error', { max: 75, skipBlankLines: true, skipComments: true }],
},
}, },
{ {
// ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns. // 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 { export function dlrFromPdu(pduObj: PduObject): Dlr | undefined {
const message = pduObj.params.short_message; const message = pduObj.params.short_message;
const receipt = typeof message === 'string' ? parseReceipt(message) : undefined; const receipt = typeof message === 'string' ? parseReceipt(message) : undefined;
const receiptState = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
const tlvState = pduObj.tlvs.message_state?.tagValue; const tlvState = pduObj.tlvs.message_state?.tagValue;
const tlvId = pduObj.tlvs.receipted_message_id?.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' const statusMsg = typeof tlvState === 'number'
? constsById.MESSAGE_STATE?.[tlvState] ? constsById.MESSAGE_STATE?.[tlvState]
: receiptStates[receipt?.stat?.toUpperCase() ?? '']; : receiptState;
if (statusMsg === undefined) return undefined; if (statusMsg === undefined) return undefined;
const statusId = typeof tlvState === 'number' const statusId = typeof tlvState === 'number'
? tlvState ? tlvState
: consts.MESSAGE_STATE[receiptStates[receipt?.stat?.toUpperCase() ?? ''] ?? 'UNKNOWN']; : consts.MESSAGE_STATE[receiptState ?? 'UNKNOWN'];
return { return {
doneDate: receiptDate(receipt?.doneDate), doneDate: receiptDate(receipt?.doneDate),
+119 -67
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 { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.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 type { Tlv } from './defs/tlvs.ts';
import { cmds, commandNameById, isCommandName } from './defs/commands.ts'; import { cmds, commandNameById, isCommandName } from './defs/commands.ts';
import { consts } from './defs/constants.ts'; import { consts } from './defs/constants.ts';
@@ -79,46 +79,31 @@ function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
return { tagId }; return { tagId };
} }
function buildPdu( /** Encoding a string short_message also settles data_coding and sm_length. */
cmdName: CommandName, function resolveShortMessage(
cmdStatus: ErrorName,
seqNr: number,
params: Record<string, ParamValue | undefined>, params: Record<string, ParamValue | undefined>,
tlvs: Record<string, TlvInput> | undefined, ): Record<string, ParamValue | undefined> {
): Result<{ buffer: Buffer }> { const message = params.short_message;
const definition = cmds[cmdName];
if (!definition) { if (typeof message !== 'string') return { ...params };
return { err: new Error(`Invalid cmdName: ${JSON.stringify(cmdName)}`) };
}
if (!isErrorName(cmdStatus)) { const dataCoding = params.data_coding;
return { err: new Error(`Invalid cmdStatus: ${JSON.stringify(cmdStatus)}`) }; const encoding = typeof dataCoding === 'number' ? encodingByDataCoding(dataCoding) : detect(message);
}
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); const encoded = encodeMessage(message, encoding);
resolved.short_message = encoded.buffer; return {
resolved.sm_length = encoded.buffer.length; ...params,
data_coding: typeof dataCoding === 'number' ? dataCoding : consts.ENCODING[encoded.encoding],
if (typeof dataCoding !== 'number') { short_message: encoded.buffer,
resolved.data_coding = consts.ENCODING[encoded.encoding]; sm_length: encoded.buffer.length,
} };
} }
function writeParams(
definition: CommandDefinition,
resolved: Record<string, ParamValue | undefined>,
cmdName: CommandName,
): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for (const [name, type] of Object.entries(definition.params ?? {})) { for (const [name, type] of Object.entries(definition.params ?? {})) {
@@ -139,6 +124,12 @@ function buildPdu(
chunks.push(chunk); 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 ?? {})) { for (const [name, tlv] of Object.entries(tlvs ?? {})) {
const tag = tagIdOf(name, tlv); const tag = tagIdOf(name, tlv);
@@ -169,7 +160,39 @@ function buildPdu(
chunks.push(chunk); 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); const header = Buffer.alloc(16);
header.writeUInt32BE(body.length + 16, 0); header.writeUInt32BE(body.length + 16, 0);
@@ -223,22 +246,11 @@ function parseTlvs(
return { offset, tlvs }; return { offset, tlvs };
} }
function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolean; pduObj: PduObject }> { function readParams(
const cmdLength = pdu.readUInt32BE(0); cmdName: CommandName,
const cmdId = pdu.readUInt32BE(4); pdu: Buffer,
const cmdName = commandNameById(cmdId); trailingNull: boolean,
): Result<{ offset: number; params: Record<string, ParamValue> }> {
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 params: Record<string, ParamValue> = {}; const params: Record<string, ParamValue> = {};
let offset = 16; let offset = 16;
@@ -255,10 +267,34 @@ function parseOnce(pdu: Buffer, trailingNull: boolean): Result<{ aligned: boolea
if (name === 'short_message' && trailingNull) offset++; 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 }; if (parsed.err) return { err: parsed.err };
const params = read.params;
const message = params.short_message; const message = params.short_message;
const esmClass = numberOr(params.esm_class, 0); 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) { if (pdu.length < 16) {
return { err: new Error(`PDU is too short, minimum is 16 octets, got ${String(pdu.length)}`) }; 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 { 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); const plain = parseOnce(pdu, false);
if (!plain.err && plain.aligned) return { pduObj: plain.pduObj }; 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 }; 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( export function pduReturn(
pdu: Buffer | PduObject, pdu: Buffer | PduObject,
status: ErrorName = 'ESME_ROK', status: ErrorName = 'ESME_ROK',
@@ -329,16 +392,5 @@ export function pduReturn(
return { err: new Error(`"${pdu.cmdName}" has no response command`) }; return { err: new Error(`"${pdu.cmdName}" has no response command`) };
} }
const respParams: Record<string, ParamValue> = { ...params }; return buildPdu(respName, status, pdu.seqNr, echoParams(respName, pdu, params), tlvs);
// 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);
} }
+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 NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls'; import type { Server as TlsServer, TlsOptions } from 'node:tls';
import { EventEmitter } from 'node:events'; 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 createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls'; import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts'; import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -45,7 +45,7 @@ const defaults = {
idleTimeout: 40_000, idleTimeout: 40_000,
interfaceVersion: defaultInterfaceVersion, interfaceVersion: defaultInterfaceVersion,
port: 2775, port: 2775,
systemId: '', systemId: defaultSystemId,
}; };
/** A listening SMPP server. Sessions arrive as `session` events; `close()` stops listening. */ /** 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; systemId?: string | undefined;
}; };
export const defaultSystemId = '';
const defaults = { const defaults = {
maxDelay: 30_000, maxDelay: 30_000,
maxOutstanding: 10, maxOutstanding: 10,
@@ -79,7 +81,7 @@ const defaults = {
minDelay: 1000, minDelay: 1000,
reassemblyTimeout: 300_000, reassemblyTimeout: 300_000,
responseTimeout: 30_000, responseTimeout: 30_000,
systemId: '', systemId: defaultSystemId,
}; };
export const bindCommands: readonly string[] = [ 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'); assert.equal(dlr.doneDate?.toISOString(), '2025-08-25T14:31:00.000Z');
}); });
test('maps every spec status code back to its message state', () => { test('maps every spec status code back to its message state and id', () => {
for (const [code, expected] of [ for (const [code, expected, statusId] of [
['DELIVRD', 'DELIVERED'], ['DELIVRD', 'DELIVERED', 2],
['UNDELIV', 'UNDELIVERABLE'], ['UNDELIV', 'UNDELIVERABLE', 5],
['EXPIRED', 'EXPIRED'], ['EXPIRED', 'EXPIRED', 3],
['DELETED', 'DELETED'], ['DELETED', 'DELETED', 4],
['ACCEPTD', 'ACCEPTED'], ['ACCEPTD', 'ACCEPTED', 6],
['REJECTD', 'REJECTED'], ['REJECTD', 'REJECTED', 8],
['ENROUTE', 'ENROUTE'], ['ENROUTE', 'ENROUTE', 1],
['UNKNOWN', 'UNKNOWN'], ['UNKNOWN', 'UNKNOWN', 7],
]) { ['delivrd', 'DELIVERED', 2],
const dlr = dlrFromPdu(deliverSm(`id:x stat:${String(code)} err:0`)); ] 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', () => { test('keeps a UDH-carrying short_message as a buffer', () => {
const message = Buffer.concat([ const message = Buffer.concat([
Buffer.from('050003010101', 'hex'), 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 - [ ] **`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 — 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. a public-surface change, so it waits for a decision.
- [ ] **`buildPdu` and `dlrFromPdu` carry a complexity of 22.** `eslint.config.js` holds them at - [ ] **Group the session's collaborators under `src/session/`.** Only `session.ts` imports
that ceiling rather than below the repo-wide 10, so neither can grow but neither shrinks. `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 - [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them
end to end. The interop suite is the natural place. end to end. The interop suite is the natural place.
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript - [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript