Close the stability findings and settle the sendResp, sendSms and unbind contracts

This commit is contained in:
2026-08-27 00:17:48 +02:00
parent 435fa42708
commit 5af75a3c57
23 changed files with 1262 additions and 202 deletions
+2
View File
@@ -42,6 +42,7 @@ src/
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts silentLog — the default when the application passes none
message.ts Encoding detection, splitting, bit counting, SMPP date formatting
@@ -53,6 +54,7 @@ src/
result.ts Result<T> — the shape every fallible call returns
send-sms.ts submitSms composition and the submitSmParams builder
send-window.ts SendWindow: the maxOutstanding semaphore
session-options.ts SessionOptions, ReconnectOptions and the session defaults
udh.ts User data header: the concatenation fields of a long SMS
uuid.ts uuidv7() — the ids the library generates for messages
defs/
+15 -6
View File
@@ -81,9 +81,10 @@ Every one is optional.
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
| `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. |
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering; with `reconnect` set, it re-binds. |
| `responseTimeout` | `30000` | How long to wait for a response before giving up on it. |
| `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. |
| `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop, with exponential backoff. |
| `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop or an idle timeout, with exponential backoff. |
| `log` | silent | A `@larvit/log` instance. |
| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
@@ -109,6 +110,11 @@ one id per segment:
const { err, pduObjs, smsIds } = await session.sendSms({ from, message, to });
```
`err` is set when the SMSC refuses a segment, and it names the status it refused with. Because every
segment goes on the wire together, `smsIds` then holds the ids of the segments the SMSC did accept —
retry only what is missing from it. A message needing more than 255 segments is refused before
anything is sent, since the concatenation header numbers segments in a single octet.
## Server
The simplest possible server — no authentication, listening on port 2775:
@@ -143,9 +149,10 @@ if (err) throw err;
smpp.on('session', session => {
session.on('sms', async sms => {
// Responding is part of the protocol, not optional.
// Defaults to ESME_ROK; see the SMPP spec for the other status codes.
// Responding is part of the protocol, not optional. Without arguments it answers
// ESME_ROK with a generated id; pass your own, and a status to refuse the message.
await sms.sendResp();
// await sms.sendResp({ smsId: yourOwnId, status: 'ESME_RMSGQFUL' });
if (sms.dlr) {
await sms.sendDlr(); // same as sms.sendDlr('DELIVERED')
@@ -196,12 +203,12 @@ is exactly what this library promises not to do.
| Event | Fires when |
| --- | --- |
| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()` and `sendDlr()`. |
| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and the `smsId` it was answered with. |
| `dlr` | A delivery report arrives, one per segment. |
| `messageDlr` | Every segment of a multipart message has been reported on. |
| `messageDlr` | Every segment of a multipart message sent with `dlr: true` has been reported on. |
| `close` | The connection closed. |
| `reconnected` | The client re-bound after a drop (only with `reconnect` configured). |
| `sessionError` | Something failed on a live session. |
| `sessionError` | Something failed on a live session, including a hook or listener that threw. |
| `data` | Raw bytes arrived on the socket. |
| `incomingPdu` | A complete PDU arrived, as a buffer. |
| `incomingPduObj` | The same PDU, parsed into an object. |
@@ -248,6 +255,8 @@ The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `
resolving to a result object with an optional `err`. Nothing rejects.
- **`server()` resolves once, when it is listening**, and gives you a handle with `close()`, `port`
and a `session` event. It no longer calls your callback once per incoming connection.
- **The id a message is answered with goes to `sendResp({ smsId })`**, and `sms.smsId` is read-only.
Assigning it no longer works, so set the id where the response is sent rather than before it.
- **`checkuserpass` is now `authenticate`**, takes `{ password, session, systemId, systemType }` and
returns `false` or `{ userData }`.
- **Renamed options:** `enqLinkTiming``enquireLinkInterval`, server `timeout``idleTimeout`.
+45 -25
View File
@@ -17,6 +17,7 @@ export type ClientOptions = {
bindType?: BindType;
enquireLinkInterval?: number;
host?: string;
idleTimeout?: number;
interfaceVersion?: number;
log?: LogInt;
maxOutstanding?: number;
@@ -34,16 +35,14 @@ const defaults = {
bindType: 'transceiver',
enquireLinkInterval: 20_000,
host: 'localhost',
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
idleTimeoutFactor: 2,
interfaceVersion: defaultInterfaceVersion,
password: 'pass',
port: 2775,
username: 'user',
} as const;
/**
* Opens the socket. 0.4.0 built a bare `new tls.Socket()` for `tls: true`, which never performs a
* handshake, so those connections were not encrypted at all.
*/
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
const host = options.host ?? defaults.host;
const port = options.port ?? defaults.port;
@@ -59,7 +58,15 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
return;
}
const sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port });
let sock: Socket;
try {
sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port });
} catch (thrown: unknown) {
resolve({ err: thrown instanceof Error ? thrown : new Error(String(thrown)) });
return;
}
const settle = (result: Result<{ sock: Socket }>): void => {
sock.removeListener('error', onError);
@@ -122,6 +129,29 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return {};
}
function createSession(options: ClientOptions, log: LogInt, sock: Socket): Session {
const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval;
return new Session({
enquireLinkInterval,
idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor,
log,
maxOutstanding: options.maxOutstanding,
responseTimeout: options.responseTimeout,
sock,
...(options.reconnect
? {
reconnect: {
connect: () => openSocket(options),
maxDelay: options.reconnect.maxDelay,
minDelay: options.reconnect.minDelay,
onConnected: reconnected => bind(reconnected, options),
},
}
: {}),
});
}
/** Connects to an SMSC and binds. */
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
const log = options.log ?? silentLog;
@@ -137,23 +167,17 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
return { err: opened.err };
}
const session = new Session({
enquireLinkInterval: options.enquireLinkInterval ?? defaults.enquireLinkInterval,
log,
maxOutstanding: options.maxOutstanding,
responseTimeout: options.responseTimeout,
sock: opened.sock,
...(options.reconnect
? {
reconnect: {
connect: () => openSocket(options),
maxDelay: options.reconnect.maxDelay,
minDelay: options.reconnect.minDelay,
onConnected: reconnected => bind(reconnected, options),
},
const session = createSession(options, log, opened.sock);
const signal = options.signal;
if (signal?.aborted === true) {
session.close();
return { err: new Error('Aborted before binding') };
}
: {}),
});
// Registered before the bind: an abort landing while it is in flight has to close the session.
signal?.addEventListener('abort', () => { session.close(); }, { once: true });
const bound = await bind(session, options);
@@ -163,9 +187,5 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
return { err: bound.err };
}
if (options.signal) {
options.signal.addEventListener('abort', () => { session.close(); }, { once: true });
}
return { session };
}
+48 -7
View File
@@ -55,6 +55,26 @@ function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
return { int: value };
}
function writeInt8(value: ParamValue, buf: Buffer, offset: number): VoidResult {
const { err, int } = wantInt(value, 0xFF);
if (err) return { err };
buf.writeUInt8(int, offset);
return {};
}
function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult {
const { err, int } = wantInt(value, 0xFFFFFFFF);
if (err) return { err };
buf.writeUInt32BE(int, offset);
return {};
}
function wantText(value: ParamValue): Result<{ text: string }> {
if (typeof value === 'string') return { text: value };
if (typeof value === 'number') return { text: value.toString() };
@@ -323,7 +343,9 @@ export const dest_address_array: WireType<DestAddress[]> = {
if (rangeErr) return { err: rangeErr };
buf.writeUInt8(addresses.length, offset++);
const count = writeInt8(addresses.length, buf, offset++);
if (count.err) return { err: count.err };
for (const dest of addresses) {
if ('dl_name' in dest) {
@@ -332,8 +354,15 @@ export const dest_address_array: WireType<DestAddress[]> = {
offset += dest.dl_name.length + 1;
} else {
buf.writeUInt8(1, offset++);
buf.writeUInt8(dest.dest_addr_ton, offset++);
buf.writeUInt8(dest.dest_addr_npi, offset++);
const ton = writeInt8(dest.dest_addr_ton, buf, offset++);
if (ton.err) return { err: ton.err };
const npi = writeInt8(dest.dest_addr_npi, buf, offset++);
if (npi.err) return { err: npi.err };
writeCstring(dest.destination_addr, buf, offset);
offset += dest.destination_addr.length + 1;
}
@@ -406,14 +435,26 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
if (rangeErr) return { err: rangeErr };
buf.writeUInt8(smes.length, offset++);
const count = writeInt8(smes.length, buf, offset++);
if (count.err) return { err: count.err };
for (const sme of smes) {
buf.writeUInt8(sme.dest_addr_ton, offset++);
buf.writeUInt8(sme.dest_addr_npi, offset++);
const ton = writeInt8(sme.dest_addr_ton, buf, offset++);
if (ton.err) return { err: ton.err };
const npi = writeInt8(sme.dest_addr_npi, buf, offset++);
if (npi.err) return { err: npi.err };
writeCstring(sme.destination_addr, buf, offset);
offset += sme.destination_addr.length + 1;
buf.writeUInt32BE(sme.error_status_code, offset);
const status = writeInt32(sme.error_status_code, buf, offset);
if (status.err) return { err: status.err };
offset += 4;
}
+54 -2
View File
@@ -1,7 +1,17 @@
import type { Dlr } from './dlr.ts';
import type { LogInt } from '@larvit/log';
import { ExpiringGroups } from './expiring-groups.ts';
export type MessageDlr = Dlr & { segments: Dlr[] };
export type DlrMergerOptions = {
log: LogInt;
max: number;
/** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined;
timeout: number;
};
type Group = {
expected: number;
parts: Map<number, Dlr>;
@@ -15,7 +25,24 @@ const numbered = /^(.*)-(\d+)$/;
* SMSC that hands out unrelated ids per segment cannot be merged, so nothing is reported for it.
*/
export class DlrMerger {
private readonly groups = new Map<string, Group>();
private readonly groups: ExpiringGroups<Group>;
private readonly log: LogInt;
private readonly max: number;
constructor(options: DlrMergerOptions) {
this.groups = new ExpiringGroups<Group>({
max: options.max,
now: options.now,
onSweep: () => { this.sweep(); },
timeout: options.timeout,
});
this.log = options.log;
this.max = options.max;
}
get size(): number {
return this.groups.size;
}
/** Registers the ids one multipart send got back, so their receipts can be merged. */
expect(smsIds: string[]): void {
@@ -34,12 +61,14 @@ export class DlrMerger {
if (bases.size !== 1) return;
for (const base of bases) {
this.groups.set(base, { expected: smsIds.length, parts: new Map() });
this.open(base, smsIds.length);
}
}
/** The whole message's report, on the receipt that completes it. */
collect(dlr: Dlr): MessageDlr | undefined {
this.sweep();
const match = numbered.exec(dlr.smsId);
const base = match?.[1];
const part = match?.[2];
@@ -61,4 +90,27 @@ export class DlrMerger {
return { ...worst, segments, smsId: base };
}
clear(): void {
this.groups.clear();
}
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
sweep(): void {
for (const [base, group] of this.groups.takeExpired()) {
this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected });
}
}
private open(base: string, expected: number): void {
if (this.groups.full) this.dropOldest();
this.groups.set(base, { expected, parts: new Map() });
}
private dropOldest(): void {
if (!this.groups.takeOldest()) return;
this.log.warn('dlrMerger - buffer full, dropping the oldest message', { max: this.max });
}
}
+1 -1
View File
@@ -115,7 +115,7 @@ export function parseReceipt(message: string): Receipt {
/**
* Builds a delivery report from a deliver_sm. The message_state and receipted_message_id TLVs are
* authoritative when present; otherwise the receipt text is parsed, which is the only thing Kannel
* and several other SMSCs send. 0.4.0 required the TLVs and rejected everything else.
* and several other SMSCs send.
*/
export function dlrFromPdu(pduObj: PduObject): Dlr | undefined {
const message = pduObj.params.short_message;
+102
View File
@@ -0,0 +1,102 @@
export type ExpiringGroupsOptions = {
max: number;
/** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined;
/** Runs on the sweeper's own timer; the owner reports whatever it takes out. */
onSweep: () => void;
timeout: number;
};
type Entry<T> = {
deadline: number;
group: T;
};
/**
* A capped store of groups that expire. Nothing is dropped silently: the owner takes the expired
* and the evicted out itself, so the accounting and the log line stay where the group is understood.
*/
export class ExpiringGroups<T> {
private readonly entries = new Map<string, Entry<T>>();
private readonly max: number;
private readonly now: () => number;
private readonly onSweep: () => void;
private readonly timeout: number;
private sweeper: NodeJS.Timeout | undefined;
constructor(options: ExpiringGroupsOptions) {
this.max = options.max;
this.now = options.now ?? Date.now;
this.onSweep = options.onSweep;
this.timeout = options.timeout;
}
get full(): boolean {
return this.entries.size >= this.max;
}
get size(): number {
return this.entries.size;
}
get(key: string): T | undefined {
return this.entries.get(key)?.group;
}
/** Starts the group's deadline, and the sweeper if this is the only group held. */
set(key: string, group: T): void {
this.entries.set(key, { deadline: this.now() + this.timeout, group });
if (this.sweeper) return;
this.sweeper = setInterval(() => { this.onSweep(); }, this.timeout);
this.sweeper.unref();
}
delete(key: string): void {
this.entries.delete(key);
this.idle();
}
clear(): void {
this.entries.clear();
this.idle();
}
/** Removes every group past its deadline and hands them over. */
takeExpired(): [string, T][] {
const now = this.now();
const taken: [string, T][] = [];
for (const [key, entry] of this.entries) {
if (entry.deadline > now) continue;
taken.push([key, entry.group]);
this.entries.delete(key);
}
this.idle();
return taken;
}
/** Removes the group held longest and hands it over. Undefined means there was none. */
takeOldest(): [string, T] | undefined {
const oldest = this.entries.entries().next();
if (oldest.done) return undefined;
const [key, entry] = oldest.value;
this.delete(key);
return [key, entry.group];
}
private idle(): void {
if (!this.sweeper || this.entries.size > 0) return;
clearInterval(this.sweeper);
this.sweeper = undefined;
}
}
+7 -1
View File
@@ -6,6 +6,9 @@ import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
/** A single SMS carries 1120 bits, whatever the alphabet. */
const singleMessageBits = 1120;
/** The concatenation UDH numbers the segments of a message in a single octet. */
export const maxSegments = 255;
/** Budget per concatenated segment: septets for GSM, octets for UCS2. */
const segmentUnits = { ASCII: 153, UCS2: 67 * 2 } as const;
@@ -52,7 +55,8 @@ export function bitCount(message: string, encoding?: EncodingName): number {
/**
* Splits a message into concatenation segments, each prefixed with a UDH. A message that fits in a
* single SMS is returned as one segment with no UDH.
* single SMS is returned as one segment with no UDH, and one needing more than `maxSegments` as no
* segments at all — a UDH cannot number them.
*
* Splitting walks code points, so an escaped GSM character never straddles a segment boundary and a
* surrogate pair is never cut in half.
@@ -84,6 +88,8 @@ export function splitMessage(message: string, options: SplitOptions): Buffer[] {
if (current !== '') parts.push(current);
if (parts.length > maxSegments) return [];
return parts.map((part, index) => Buffer.concat([
Buffer.from([0x05, 0x00, 0x03, options.reference & 0xFF, parts.length, index + 1]),
encodings[encoding].encode(part),
+11 -2
View File
@@ -79,12 +79,18 @@ function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
return { tagId };
}
/** Encoding a string short_message also settles data_coding and sm_length. */
/** Encoding a string short_message settles data_coding and sm_length; a buffer settles sm_length. */
function resolveShortMessage(
params: Record<string, ParamValue | undefined>,
): Record<string, ParamValue | undefined> {
const message = params.short_message;
if (Buffer.isBuffer(message)) {
return params.sm_length === undefined
? { ...params, sm_length: message.length }
: { ...params };
}
if (typeof message !== 'string') return { ...params };
const dataCoding = params.data_coding;
@@ -355,7 +361,10 @@ 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. */
/**
* Fields the response shares with the request are echoed back unless the caller overrode them, so a
* response that has to state its own value for one — an SMSC's system_id — must pass it.
*/
function echoParams(
respName: CommandName,
pdu: PduObject,
+81 -36
View File
@@ -2,23 +2,60 @@ import type { ConcatInfo } from './udh.ts';
import type { LogInt } from '@larvit/log';
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts';
import type { Tlv } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts';
import { paramText } from './defs/types.ts';
export type ReassemblerOptions = {
log: LogInt;
max: number;
maxOctets?: number | undefined;
/** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined;
timeout: number;
};
const defaultMaxOctets = 64 * 1024 * 1024;
type Group = {
deadline: number;
octets: number;
parts: Map<number, PduObject>;
total: number;
};
/** Wire reads hand back views, so retaining one segment would pin the whole PDU it arrived in. */
function detach(pduObj: PduObject): PduObject {
const params: Record<string, ParamValue> = {};
const tlvs: Record<string, Tlv> = {};
for (const [name, value] of Object.entries(pduObj.params)) {
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
}
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
tlvs[name] = Buffer.isBuffer(tlv.tagValue)
? { ...tlv, tagValue: Buffer.from(tlv.tagValue) }
: tlv;
}
return { ...pduObj, params, tlvs };
}
function octetsOf(pduObj: PduObject): number {
let octets = 0;
for (const value of Object.values(pduObj.params)) {
if (Buffer.isBuffer(value)) octets += value.length;
}
for (const tlv of Object.values(pduObj.tlvs)) {
if (Buffer.isBuffer(tlv.tagValue)) octets += tlv.tagValue.length;
}
return octets;
}
function groupKey(pduObj: PduObject, reference: number): string {
return [
paramText(pduObj.params.source_addr),
@@ -52,18 +89,22 @@ export function decodeSegments(pduObjs: PduObject[]): string {
/** Holds the segments of incomplete multipart messages until they are whole, capped and expiring. */
export class Reassembler {
private readonly groups = new Map<string, Group>();
private readonly groups: ExpiringGroups<Group>;
private readonly log: LogInt;
private readonly max: number;
private readonly now: () => number;
private readonly timeout: number;
private sweeper: NodeJS.Timeout | undefined;
private readonly maxOctets: number;
private octets = 0;
constructor(options: ReassemblerOptions) {
this.groups = new ExpiringGroups<Group>({
max: options.max,
now: options.now,
onSweep: () => { this.sweep(); },
timeout: options.timeout,
});
this.log = options.log;
this.max = options.max;
this.now = options.now ?? Date.now;
this.timeout = options.timeout;
this.maxOctets = options.maxOctets ?? defaultMaxOctets;
}
get size(): number {
@@ -76,64 +117,68 @@ export class Reassembler {
const key = groupKey(pduObj, concat.reference);
const group = this.groups.get(key) ?? this.open(key, concat.total);
const replaced = group.parts.get(concat.part);
const segment = detach(pduObj);
const delta = octetsOf(segment) - (replaced === undefined ? 0 : octetsOf(replaced));
group.parts.set(concat.part, pduObj);
group.parts.set(concat.part, segment);
group.octets += delta;
this.octets += delta;
if (group.parts.size < group.total) return undefined;
if (group.parts.size < group.total) {
this.trim();
return undefined;
}
this.groups.delete(key);
this.idle();
this.octets -= group.octets;
return [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, part]) => part);
}
clear(): void {
this.groups.clear();
this.idle();
this.octets = 0;
}
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
sweep(): void {
const now = this.now();
for (const [key, group] of this.groups) {
if (group.deadline > now) continue;
for (const [key, group] of this.groups.takeExpired()) {
this.log.info('reassembler - incomplete message expired', { key, total: group.total });
this.groups.delete(key);
this.octets -= group.octets;
}
this.idle();
}
private open(key: string, total: number): Group {
if (this.groups.size >= this.max) this.dropOldest();
if (this.groups.full) this.dropOldest();
const group: Group = { deadline: this.now() + this.timeout, parts: new Map(), total };
const group: Group = { octets: 0, parts: new Map(), total };
this.groups.set(key, group);
if (!this.sweeper) {
this.sweeper = setInterval(() => { this.sweep(); }, this.timeout);
this.sweeper.unref();
}
return group;
}
/** Drops the oldest groups until the retained payload is back under the octet cap. */
private trim(): void {
while (this.octets > this.maxOctets && this.groups.size > 0) {
this.dropOldest();
}
}
private dropOldest(): void {
const oldest = this.groups.keys().next();
const oldest = this.groups.takeOldest();
if (oldest.done) return;
if (!oldest) return;
this.log.warn('reassembler - buffer full, dropping the oldest message', { max: this.max });
this.groups.delete(oldest.value);
}
const [, group] = oldest;
private idle(): void {
if (!this.sweeper || this.groups.size > 0) return;
clearInterval(this.sweeper);
this.sweeper = undefined;
this.log.warn('reassembler - buffer full, dropping the oldest message', {
max: this.max,
maxOctets: this.maxOctets,
octets: this.octets,
});
this.octets -= group.octets;
}
}
+26 -7
View File
@@ -14,6 +14,7 @@ export type ReconnectLoopOptions = {
/** Reopens a dropped connection, backing off between attempts until it is told to stop. */
export class ReconnectLoop {
private readonly options: ReconnectLoopOptions;
private attempting = false;
private delay: number;
private halted = false;
private timer: NodeJS.Timeout | undefined;
@@ -29,7 +30,7 @@ export class ReconnectLoop {
}
schedule(): void {
if (this.timer || this.isStopped()) return;
if (this.timer || this.attempting || this.isStopped()) return;
const delay = this.delay;
@@ -53,7 +54,25 @@ export class ReconnectLoop {
}
private async run(): Promise<void> {
if (this.isStopped()) return;
this.attempting = true;
// connect() and onConnected() are the application's, so a throw from either lands here.
const retry = await this.attempt().catch((thrown: unknown) => {
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
this.options.log.error('reconnect - an attempt threw', { message: err.message });
return true;
});
this.attempting = false;
if (retry) this.schedule();
}
/** True means the attempt failed and the loop should try again. */
private async attempt(): Promise<boolean> {
if (this.isStopped()) return false;
const opened = await this.options.connect();
@@ -61,26 +80,26 @@ export class ReconnectLoop {
this.options.log.warn('reconnect - could not open a socket', {
message: opened.err.message,
});
this.schedule();
return;
return true;
}
if (this.isStopped()) {
opened.sock.destroy();
return;
return false;
}
const up = await this.options.onConnected(opened.sock);
if (up.err) {
this.options.log.warn('reconnect - could not come back up', { message: up.err.message });
this.schedule();
return;
return true;
}
this.delay = this.options.minDelay;
return false;
}
}
+24 -6
View File
@@ -6,7 +6,7 @@ import type { Result } from './result.ts';
import { consts } from './defs/constants.ts';
import { detect } from './defs/encodings.ts';
import { paramText } from './defs/types.ts';
import { smppTime, splitMessage } from './message.ts';
import { maxSegments, smppTime, splitMessage } from './message.ts';
export type SendSmsOptions = {
dlr?: boolean;
@@ -23,7 +23,8 @@ export type SendSmsOptions = {
validityPeriod?: Date | number | string;
};
export type SendSmsResult = Result<{ pduObjs: PduObject[]; smsIds: string[] }>;
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
export type SendSmsResult = { err?: Error; pduObjs: PduObject[]; smsIds: string[] };
/** What sending needs from the session: a concat reference and a way onto the wire. */
export type SendSmsDeps = {
@@ -37,7 +38,7 @@ type SegmentOptions = {
multipart: boolean;
};
/** Alphanumeric senders must be TON 5; 0.4.0 sent everything as TON 1 (international). */
/** Alphanumeric senders must be TON 5. */
function addressTon(address: string): number {
return /^\+?\d+$/.test(address) ? consts.TON.INTERNATIONAL : consts.TON.ALPHANUMERIC;
}
@@ -82,6 +83,15 @@ export function submitSmParams(
export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise<SendSmsResult> {
const encoding = sms.encoding ?? detect(sms.message);
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
if (segments.length === 0) {
return {
err: new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`),
pduObjs: [],
smsIds: [],
};
}
const multipart = segments.length > 1;
const pduObjs: PduObject[] = [];
const smsIds: string[] = [];
@@ -95,12 +105,20 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise
params: submitSmParams(sms, segment, { encoding, multipart }),
})));
for (const one of sent) {
if (one.err) return { err: one.err };
let failure: Error | undefined;
for (const one of sent) {
if (one.err) {
failure ??= one.err;
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
pduObjs.push(one.pduObj);
smsIds.push(paramText(one.pduObj.params.message_id));
} else {
const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId);
failure ??= new Error(`submit_sm refused by the peer: ${refusal}`);
}
}
return { pduObjs, smsIds };
return failure ? { err: failure, pduObjs, smsIds } : { pduObjs, smsIds };
}
+21 -4
View File
@@ -128,7 +128,6 @@ async function acceptBind(
async function onBind(session: Session, pduObj: PduObject, options: ServerOptions): Promise<void> {
const log = options.log ?? silentLog;
// Explicit, or pduReturn's echo answers the ESME with its own system_id instead of ours.
const identity = { system_id: options.systemId ?? defaults.systemId };
const systemId = paramText(pduObj.params.system_id);
@@ -211,6 +210,7 @@ function createListener(
): Result<{ listener: NetServer | TlsServer; useTls: boolean }> {
const useTls = options.tls !== undefined && options.tls !== false;
const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined;
const version = options.interfaceVersion ?? defaults.interfaceVersion;
if (useTls && !tlsOptions) {
log.warn('server - tls without a certificate', { port });
@@ -218,6 +218,13 @@ function createListener(
return { err: new Error('Listening over TLS needs tls: { cert, key }') };
}
// An int8 TLV on every bind response: out of range here means no ESME can ever bind.
if (!Number.isInteger(version) || version < 0 || version > 0xFF) {
log.warn('server - interface version out of range', { interfaceVersion: version });
return { err: new Error(`interfaceVersion must be 0-255, got ${String(version)}`) };
}
return {
listener: tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(),
useTls,
@@ -235,9 +242,15 @@ function onListening(listener: NetServer, smpp: SmppServer, options: ServerOptio
log.info('server - listening', { host: options.host ?? '*', port: smpp.port });
if (options.signal) {
options.signal.addEventListener('abort', () => { void smpp.close(); }, { once: true });
}
// close() runs the application's own 'close' listeners, so a throw from one lands here.
options.signal?.addEventListener('abort', () => {
void smpp.close().catch((thrown: unknown) => {
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
log.warn('server - could not close on abort', { message: err.message });
smpp.emit('serverError', err);
});
}, { once: true });
}
/** Starts listening for SMPP connections. Resolves once the socket is bound. */
@@ -264,10 +277,14 @@ export function server(options: ServerOptions = {}): Promise<Result<{ server: Sm
listener.once('error', onStartupError);
try {
listener.listen(port, options.host, () => {
listener.removeListener('error', onStartupError);
onListening(listener, smpp, options);
resolve({ server: smpp });
});
} catch (thrown: unknown) {
onStartupError(thrown instanceof Error ? thrown : new Error(String(thrown)));
}
});
}
+53
View File
@@ -0,0 +1,53 @@
import type { LogInt } from '@larvit/log';
import type { PduObject } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
import type { Socket } from 'node:net';
export type SendOptions = { signal?: AbortSignal | undefined };
/**
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller
* supplies how to open a socket and what to do once it is open (bind, for a client).
*/
export type ReconnectOptions = {
connect: () => Promise<Result<{ sock: Socket }>>;
maxDelay?: number | undefined;
minDelay?: number | undefined;
onConnected: (session: Session) => Promise<VoidResult>;
};
export type SessionOptions = {
enquireLinkInterval?: number | undefined;
idleTimeout?: number | undefined;
log?: LogInt | undefined;
maxOutstanding?: number | undefined;
maxReassembly?: number | undefined;
/**
* First refusal on every incoming request. Returning true means the hook answered it and the
* built-in handling is skipped — this is how the server owns bind without the session also
* replying "invalid command".
*/
onRequest?: ((session: Session, pduObj: PduObject) => Promise<boolean>) | undefined;
reassemblyTimeout?: number | undefined;
reconnect?: ReconnectOptions | undefined;
responseTimeout?: number | undefined;
sock: Socket;
/** This end's own identity, answered to the peer in place of the one it sent. */
systemId?: string | undefined;
};
export const defaultSystemId = '';
export const defaults = {
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
dlrMergeTimeout: 86_400_000,
maxDelay: 30_000,
maxDlrMerges: 1000,
maxOutstanding: 10,
maxReassembly: 1000,
minDelay: 1000,
reassemblyTimeout: 300_000,
responseTimeout: 30_000,
systemId: defaultSystemId,
};
+38 -55
View File
@@ -4,6 +4,7 @@ import type { LogInt } from '@larvit/log';
import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { ReconnectOptions, SendOptions, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { Sms } from './sms.ts';
@@ -19,13 +20,15 @@ import { SendWindow } from './send-window.ts';
import { concatInfo } from './udh.ts';
import { consts, optionalParamsMinVersion } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { defaultSystemId, defaults } from './session-options.ts';
import { dlrFromPdu } from './dlr.ts';
import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
import { paramText } from './defs/types.ts';
import { silentLog } from './log.ts';
import { submitSms } from './send-sms.ts';
export type { MessageDlr, SendSmsOptions };
export type { MessageDlr, ReconnectOptions, SendOptions, SendSmsOptions, SessionOptions };
export { defaultSystemId };
export type SessionEvents = {
close: [];
@@ -39,51 +42,6 @@ export type SessionEvents = {
sms: [Sms];
};
export type SendOptions = { signal?: AbortSignal | undefined };
/**
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller
* supplies how to open a socket and what to do once it is open (bind, for a client).
*/
export type ReconnectOptions = {
connect: () => Promise<Result<{ sock: Socket }>>;
maxDelay?: number | undefined;
minDelay?: number | undefined;
onConnected: (session: Session) => Promise<VoidResult>;
};
export type SessionOptions = {
enquireLinkInterval?: number | undefined;
idleTimeout?: number | undefined;
log?: LogInt | undefined;
maxOutstanding?: number | undefined;
maxReassembly?: number | undefined;
/**
* First refusal on every incoming request. Returning true means the hook answered it and the
* built-in handling is skipped — this is how the server owns bind without the session also
* replying "invalid command".
*/
onRequest?: ((session: Session, pduObj: PduObject) => Promise<boolean>) | undefined;
reassemblyTimeout?: number | undefined;
reconnect?: ReconnectOptions | undefined;
responseTimeout?: number | undefined;
sock: Socket;
/** This end's own identity, answered to the peer in place of the one it sent. */
systemId?: string | undefined;
};
export const defaultSystemId = '';
const defaults = {
maxDelay: 30_000,
maxOutstanding: 10,
maxReassembly: 1000,
minDelay: 1000,
reassemblyTimeout: 300_000,
responseTimeout: 30_000,
systemId: defaultSystemId,
};
export const bindCommands: readonly string[] = [
'bind_receiver',
'bind_transceiver',
@@ -100,7 +58,7 @@ export class Session extends EventEmitter<SessionEvents> {
peerInterfaceVersion: number | undefined = undefined;
userData: unknown = undefined;
private readonly dlrMerger = new DlrMerger();
private readonly dlrMerger: DlrMerger;
private readonly options: SessionOptions;
private readonly pending: PendingRequests;
private readonly reassembler: Reassembler;
@@ -117,6 +75,11 @@ export class Session extends EventEmitter<SessionEvents> {
this.log = options.log ?? silentLog;
this.options = options;
this.dlrMerger = new DlrMerger({
log: this.log,
max: defaults.maxDlrMerges,
timeout: defaults.dlrMergeTimeout,
});
this.pending = new PendingRequests(this.log);
this.reassembler = new Reassembler({
log: this.log,
@@ -130,7 +93,8 @@ export class Session extends EventEmitter<SessionEvents> {
idleTimeout: options.idleTimeout,
log: this.log,
onEnquireLink: () => { void this.send({ cmdName: 'enquire_link' }); },
onIdle: () => { this.close(); },
// Not close(): a link that went quiet is a drop, and a drop is what reconnect is for.
onIdle: () => { this.teardown(); },
});
this.window = new SendWindow(options.maxOutstanding ?? defaults.maxOutstanding);
@@ -172,10 +136,18 @@ export class Session extends EventEmitter<SessionEvents> {
tlvs?: Record<string, TlvInput>,
): Promise<VoidResult> {
const built = pduReturn(pdu, status, params, tlvs);
const sent = built.err ? { err: built.err } : this.write(built.buffer);
if (built.err) return { err: built.err };
if (sent.err) {
this.log.warn('session - could not answer a request', {
cmdName: pdu.cmdName,
message: sent.err.message,
seqNr: pdu.seqNr,
});
this.emit('sessionError', sent.err);
}
return Promise.resolve(this.write(built.buffer));
return Promise.resolve(sent);
}
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
@@ -185,18 +157,20 @@ export class Session extends EventEmitter<SessionEvents> {
send: input => this.send(input, options),
}, sms);
if (!sent.err) this.dlrMerger.expect(sent.smsIds);
if (!sent.err && sms.dlr === true) this.dlrMerger.expect(sent.smsIds);
return sent;
}
/** Unbinds politely, then closes. */
/** Unbinds politely, then closes. Many SMSCs drop the link instead of answering, which is fine. */
async unbind(): Promise<VoidResult> {
const wasOpen = !this.closed;
const sent = await this.send({ cmdName: 'unbind' });
const closedOnUnbind = wasOpen && this.closed;
this.close();
return sent.err ? { err: sent.err } : {};
return sent.err && !closedOnUnbind ? { err: sent.err } : {};
}
/** Closes for good. A session closed this way never reconnects. */
@@ -240,6 +214,9 @@ export class Session extends EventEmitter<SessionEvents> {
/** Wires a freshly opened socket into this session, replacing any previous one. */
private attach(sock: Socket): void {
// The socket being replaced is already dead, and its three handlers still point here.
if (this.sock !== sock) this.sock.removeAllListeners();
this.sock = sock;
this.framer = new PduFramer();
this.closed = false;
@@ -283,6 +260,7 @@ export class Session extends EventEmitter<SessionEvents> {
this.closed = true;
this.timers.clear();
this.pending.settleAll(new Error('Session closed before a response arrived'));
this.dlrMerger.clear();
this.reassembler.clear();
this.sock.destroy();
this.emit('close');
@@ -355,7 +333,13 @@ export class Session extends EventEmitter<SessionEvents> {
}
this.emit('incomingPduObj', pduObj);
void this.handle(pduObj);
// Every application hook and listener reached from an incoming PDU funnels through here.
void this.handle(pduObj).catch((thrown: unknown) => {
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
this.log.error('session - a handler threw', { message: err.message });
this.emit('sessionError', err);
});
}
private async handle(pduObj: PduObject): Promise<void> {
@@ -385,7 +369,6 @@ export class Session extends EventEmitter<SessionEvents> {
private async unhandled(pduObj: PduObject): Promise<void> {
if (bindCommands.includes(pduObj.cmdName)) {
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
// Explicit, or pduReturn's echo answers the peer with its own system_id instead of ours.
await this.sendReturn(pduObj, 'ESME_RALYBND', {
system_id: this.options.systemId ?? defaults.systemId,
});
+27 -8
View File
@@ -8,6 +8,12 @@ import { receiptCodes } from './dlr.ts';
import { smppDate } from './message.ts';
import { uuidv7 } from './uuid.ts';
export type SendRespOptions = {
/** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */
smsId?: string;
status?: ErrorName;
};
/**
* A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying
* every segment's PDU.
@@ -21,10 +27,10 @@ export type Sms = {
/** Sends a delivery report back to the sender. Defaults to DELIVERED. */
sendDlr: (status?: MessageState) => Promise<Result<{ pduObjs: PduObject[] }>>;
/** Answers every segment. Part of the protocol, not optional. Defaults to ESME_ROK. */
sendResp: (status?: ErrorName) => Promise<VoidResult>;
sendResp: (options?: SendRespOptions) => Promise<VoidResult>;
session: Session;
/** Generated as a UUID v7 unless the application sets its own before answering. */
smsId: string;
/** The id answered to the peer: what sendResp() was given, or a generated UUID v7. */
readonly smsId: string;
submitTime: Date;
to: string;
};
@@ -46,6 +52,7 @@ export function createSms(input: SmsInput): Sms {
const first = input.pduObjs[0];
const registered = first?.params.registered_delivery;
const dataCoding = first?.params.data_coding;
const answered = { smsId: uuidv7() };
const sms: Sms = {
dlr: typeof registered === 'number' && registered !== 0,
@@ -54,9 +61,11 @@ export function createSms(input: SmsInput): Sms {
message: input.message,
pduObjs: input.pduObjs,
sendDlr: status => sendDlr(sms, status),
sendResp: status => sendResp(sms, status),
sendResp: options => sendResp(sms, answered, options ?? {}),
session: input.session,
smsId: uuidv7(),
get smsId(): string {
return answered.smsId;
},
submitTime: new Date(),
to: input.to,
};
@@ -64,17 +73,27 @@ export function createSms(input: SmsInput): Sms {
return sms;
}
async function sendResp(sms: Sms, status: ErrorName = 'ESME_ROK'): Promise<VoidResult> {
async function sendResp(
sms: Sms,
answered: { smsId: string },
options: SendRespOptions,
): Promise<VoidResult> {
const total = sms.pduObjs.length;
if (total === 0) {
return { err: new Error('No PDUs to answer') };
}
if (options.smsId === '') {
return { err: new Error('smsId must not be empty') };
}
if (options.smsId !== undefined) answered.smsId = options.smsId;
const results = await Promise.all(sms.pduObjs.map((pduObj, index) => sms.session.sendReturn(
pduObj,
status,
{ message_id: segmentId(sms.smsId, index, total) },
options.status ?? 'ESME_ROK',
{ message_id: segmentId(answered.smsId, index, total) },
)));
return results.find(result => result.err) ?? {};
+5
View File
@@ -73,6 +73,11 @@ describe('splitMessage()', () => {
assert.deepEqual(segments[1]?.subarray(0, 6), Buffer.from([0x05, 0x00, 0x03, 0x2A, 2, 2]));
});
test('produces no segments at all for a message no UDH can number', () => {
assert.equal(splitMessage('a'.repeat(153 * 255), { reference: 1 }).length, 255);
assert.equal(splitMessage('a'.repeat(153 * 255 + 1), { reference: 1 }).length, 0);
});
test('splits on characters, never inside an escape sequence', () => {
const segments = splitMessage('€'.repeat(100), { reference: 1 });
const rejoined = segments
+51
View File
@@ -157,6 +157,23 @@ describe('encoding submit_sm', () => {
assert.equal(pduObj.params.short_message.toString('hex'), '05000301010168656a2076c3a4726c64656e');
});
test('derives sm_length from a Buffer short_message the caller gave no length for', () => {
const message = Buffer.concat([Buffer.from('050003010201', 'hex'), Buffer.from('hej')]);
const pduObj = decode(encode({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
esm_class: 0x40,
short_message: message,
source_addr: '46701113311',
},
seqNr: 12,
}));
assert.equal(pduObj.params.sm_length, message.length);
assert.deepEqual(pduObj.params.short_message, message);
});
test('accepts a number for a C-string parameter', () => {
const pduObj = decode(encode({
cmdName: 'submit_sm_resp',
@@ -185,6 +202,40 @@ describe('encoding submit_sm', () => {
});
});
describe('encoding submit_multi', () => {
const dest = { dest_addr_npi: 1, dest_addr_ton: 1, destination_addr: '46709771337' };
test('reports a value the destination structures cannot hold instead of throwing', () => {
const badTon = objToPdu({
cmdName: 'submit_multi',
params: {
dest_address: [{ ...dest, dest_addr_ton: 999 }],
short_message: 'hi',
source_addr: '46701113311',
},
});
const tooMany = objToPdu({
cmdName: 'submit_multi',
params: {
dest_address: Array.from({ length: 300 }, () => dest),
short_message: 'hi',
source_addr: '46701113311',
},
});
const badStatus = objToPdu({
cmdName: 'submit_multi_resp',
params: {
message_id: '01a03ff4-737f-7c01-91db-cb14aa779bcf',
unsuccess_sme: [{ ...dest, error_status_code: 0x1FFFFFFFF }],
},
});
assert.ok(badTon.err instanceof Error);
assert.ok(tooMany.err instanceof Error);
assert.ok(badStatus.err instanceof Error);
});
});
describe('TLVs', () => {
test('extracts TLVs from a delivery receipt captured from an SMSC', () => {
const pduObj = decode(Buffer.from(
+141 -6
View File
@@ -1,14 +1,19 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test, { describe } from 'node:test';
import type { ErrorName } from '../src/defs/errors.ts';
import type { MessageDlr } from '../src/session.ts';
import type { PduObject } from '../src/pdu.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Result } from '../src/result.ts';
import type { SendSmsResult } from '../src/send-sms.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
import { Reassembler } from '../src/reassembly.ts';
import { Reassembler, decodeSegments } from '../src/reassembly.ts';
import { client } from '../src/client.ts';
import { errors } from '../src/defs/errors.ts';
import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts';
import { submitSms } from '../src/send-sms.ts';
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
const { err, server: smpp } = await server({ ...options, port: 0 });
@@ -41,8 +46,7 @@ describe('merged delivery reports', () => {
const [sms] = await Promise.all([
incoming.then(async received => {
received.smsId = 'merge-me';
await received.sendResp();
await received.sendResp({ smsId: 'merge-me' });
return received;
}),
@@ -80,8 +84,7 @@ describe('merged delivery reports', () => {
const [sms] = await Promise.all([
incoming.then(async received => {
received.smsId = 'partly-failed';
await received.sendResp();
await received.sendResp({ smsId: 'partly-failed' });
return received;
}),
@@ -105,6 +108,100 @@ describe('merged delivery reports', () => {
});
});
describe('sendSms()', () => {
function submitResp(seqNr: number, messageId: string, status: ErrorName = 'ESME_ROK'): PduObject {
return {
cmdId: 0x80000004,
cmdLength: 0,
cmdName: 'submit_sm_resp',
cmdStatus: status,
cmdStatusId: errors[status],
params: { message_id: messageId },
seqNr,
tlvs: {},
};
}
/** Three segments, each answered by whatever the caller decides for that part. */
function sendSegments(
answer: (part: number) => Result<{ pduObj: PduObject }>,
): Promise<SendSmsResult> {
let part = 0;
return submitSms(
{
log: silentLog,
reference: 7,
send: () => {
part++;
return Promise.resolve(answer(part));
},
},
{ from: '46701113311', message: 'x'.repeat(400), to: '46709771337' },
);
}
test('reports a submit_sm the peer refused instead of an empty message id', async () => {
const smpp = await startServer();
const incoming = once<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const { session } = await client({ port: smpp.port });
assert.ok(session);
const [, sent] = await Promise.all([
incoming.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })),
session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }),
]);
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /ESME_RMSGQFUL/);
session.close();
await smpp.close();
});
// A retry that repeats the segments the SMSC already took bills the recipient twice.
test('hands back the ids that landed when a segment fails', async () => {
const refused = await sendSegments(part => part === 2
? { pduObj: submitResp(part, '', 'ESME_RMSGQFUL') }
: { pduObj: submitResp(part, `landed-${String(part)}`) });
assert.ok(refused.err instanceof Error);
assert.match(refused.err.message, /ESME_RMSGQFUL/);
assert.equal(refused.pduObjs.length, 2);
assert.deepEqual(refused.smsIds, ['landed-1', 'landed-3']);
const unanswered = await sendSegments(part => part === 2
? { err: new Error('No response to seqNr 2') }
: { pduObj: submitResp(part, `landed-${String(part)}`) });
assert.ok(unanswered.err instanceof Error);
assert.deepEqual(unanswered.smsIds, ['landed-1', 'landed-3']);
});
test('refuses a message needing more segments than a UDH can number', async () => {
const attempts: PduObjectInput[] = [];
const sent = await submitSms(
{
log: silentLog,
reference: 1,
send: input => {
attempts.push(input);
return Promise.resolve({ err: new Error('nothing should reach the wire') });
},
},
{ from: '46701113311', message: 'a'.repeat(153 * 256), to: '46709771337' },
);
assert.ok(sent.err instanceof Error);
assert.equal(attempts.length, 0);
});
});
describe('reconnect', () => {
test('re-binds after the connection drops, keeping the same session object', async () => {
const smpp = await startServer();
@@ -231,6 +328,44 @@ describe('reassembly bounds', () => {
reassembler.clear();
});
test('drops the oldest incomplete message once the retained octets exceed the cap', () => {
const reassembler = new Reassembler({
log: silentLog,
max: 10,
maxOctets: 30,
now: () => 0,
timeout: 60_000,
});
for (const reference of [1, 2, 3]) {
assert.equal(collect(reassembler, reference, 1, 2), undefined);
}
assert.equal(reassembler.size, 2);
assert.equal(collect(reassembler, 1, 2, 2), undefined);
reassembler.clear();
});
// A retained subarray keeps its whole framed PDU alive, up to maxPduLength per segment.
test('copies a segment out of the buffer it arrived in', () => {
const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 });
const framed = Buffer.alloc(1024);
const first = segment(6, 1, 2);
Buffer.concat([Buffer.from([0x05, 0x00, 0x03, 6, 2, 1]), Buffer.from('fragment')]).copy(framed);
first.params.short_message = framed.subarray(0, 14);
assert.equal(reassembler.collect(first, { part: 1, reference: 6, total: 2 }), undefined);
framed.fill(0x00);
const whole = collect(reassembler, 6, 2, 2);
assert.ok(whole);
assert.equal(decodeSegments(whole), 'fragmentfragment');
});
test('expires an incomplete message once its timeout has passed', () => {
let now = 0;
const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => now, timeout: 60 });
+458 -10
View File
@@ -1,15 +1,19 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test, { describe } from 'node:test';
import type { Dlr } from '../src/dlr.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
import { DlrMerger } from '../src/dlr-merger.ts';
import { PduFramer } from '../src/pdu-framer.ts';
import { ReconnectLoop } from '../src/reconnect-loop.ts';
import { Session, bindCommands } from '../src/session.ts';
import { client } from '../src/client.ts';
import { isCommand, objToPdu, pduToObj } from '../src/pdu.ts';
import { isCommand, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts';
import { paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts';
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
const { err, server: smpp } = await server({ ...options, port: 0 });
@@ -28,6 +32,86 @@ function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>(resolve => { register(resolve); });
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
/** Polls until the condition holds; false means it never did within the budget. */
async function waitFor(condition: () => boolean, budget = 2000): Promise<boolean> {
const deadline = Date.now() + budget;
while (!condition()) {
if (Date.now() > deadline) return false;
await delay(5);
}
return true;
}
function raceWithin<T>(ms: number, promise: Promise<T>): Promise<T | false> {
return Promise.race([promise, delay(ms).then((): false => false)]);
}
type Peer = { close: () => Promise<void>; port: number };
/** Answers binds and nothing else, which is what a link the peer has stopped serving looks like. */
async function bindOnlyPeer(options: { dropOn?: string } = {}): Promise<Peer> {
const sockets: net.Socket[] = [];
const listener = net.createServer(sock => {
const framer = new PduFramer();
sockets.push(sock);
sock.on('data', chunk => {
framer.push(chunk);
for (const pdu of framer.next().pdus ?? []) {
const { pduObj } = pduToObj(pdu);
if (pduObj && pduObj.cmdName === options.dropOn) {
sock.destroy();
return;
}
if (!pduObj || !bindCommands.includes(pduObj.cmdName)) continue;
const { buffer } = pduReturn(pduObj, 'ESME_ROK', { system_id: 'silent' });
if (buffer) sock.write(buffer);
}
});
});
await new Promise<void>(resolve => { listener.listen(0, resolve); });
const address = listener.address();
return {
close: async () => {
for (const sock of sockets) {
sock.destroy();
}
await new Promise<void>(resolve => { listener.close(() => { resolve(); }); });
},
port: typeof address === 'object' && address !== null ? address.port : 0,
};
}
function enquireLink(seqNr: number): PduObject {
return {
cmdId: 0x00000015,
cmdLength: 16,
cmdName: 'enquire_link',
cmdStatus: 'ESME_ROK',
cmdStatusId: 0,
params: {},
seqNr,
tlvs: {},
};
}
type RawPeer = {
close: () => void;
/** The next PDU the server sends, queued so none is missed between reads. */
@@ -110,6 +194,42 @@ describe('bind', () => {
await smpp.close();
});
// Plenty of SMSCs drop the connection on unbind instead of answering it.
test('takes a close that follows our unbind as a clean unbind', async () => {
const peer = await bindOnlyPeer({ dropOn: 'unbind' });
const { session } = await client({ port: peer.port, responseTimeout: 2000 });
assert.ok(session);
assert.deepEqual(await session.unbind(), {});
await peer.close();
});
test('still reports a close that lands on another in-flight request', async () => {
const peer = await bindOnlyPeer({ dropOn: 'enquire_link' });
const { session } = await client({ port: peer.port, responseTimeout: 2000 });
assert.ok(session);
const sent = await session.send({ cmdName: 'enquire_link' });
assert.ok(sent.err instanceof Error);
assert.equal(sent.err.message, 'Session closed before a response arrived');
session.close();
await peer.close();
});
test('reports an unbind the peer left unanswered on a link that stays up', async () => {
const peer = await bindOnlyPeer();
const { session } = await client({ port: peer.port, responseTimeout: 150 });
assert.ok(session);
assert.ok((await session.unbind()).err instanceof Error);
await peer.close();
});
test('reports the resolved port when 0 was requested', async () => {
const smpp = await startServer();
@@ -283,8 +403,10 @@ describe('sending', () => {
const [sms, sent] = await Promise.all([
incoming.then(async received => {
received.smsId = 'fixed-id';
await received.sendResp();
const refused = await received.sendResp({ smsId: '' });
assert.ok(refused.err instanceof Error);
await received.sendResp({ smsId: 'fixed-id' });
return received;
}),
@@ -295,6 +417,7 @@ describe('sending', () => {
assert.equal(sms.to, '46709771337');
assert.equal(sms.message, 'hello world');
assert.equal(sms.dlr, false);
assert.equal(sms.smsId, 'fixed-id');
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, ['fixed-id']);
@@ -321,8 +444,7 @@ describe('sending', () => {
const [sms, sent] = await Promise.all([
incoming.then(async received => {
received.smsId = 'long-id';
await received.sendResp();
await received.sendResp({ smsId: 'long-id' });
return received;
}),
@@ -332,6 +454,7 @@ describe('sending', () => {
assert.equal(sms.message, message);
assert.ok(sms.pduObjs.length > 1);
assert.equal(sent.err, undefined);
assert.equal(sent.pduObjs.length, 4);
assert.deepEqual(sent.smsIds, ['long-id-1', 'long-id-2', 'long-id-3', 'long-id-4']);
session.close();
@@ -358,6 +481,7 @@ describe('sending', () => {
]);
assert.equal(sms.message, message);
assert.match(sms.smsId, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
session.close();
await smpp.close();
@@ -407,8 +531,7 @@ describe('delivery reports', () => {
const [sms] = await Promise.all([
incoming.then(async received => {
received.smsId = 'dlr-id';
await received.sendResp();
await received.sendResp({ smsId: 'dlr-id' });
return received;
}),
@@ -449,8 +572,7 @@ describe('delivery reports', () => {
const [sms] = await Promise.all([
incoming.then(async received => {
received.smsId = 'fail-id';
await received.sendResp();
await received.sendResp({ smsId: 'fail-id' });
return received;
}),
@@ -506,6 +628,39 @@ describe('delivery reports', () => {
peer.close();
await smpp.close();
});
test('merges nothing for a message that asked for no receipt', async () => {
const smpp = await startServer();
const incoming = once<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const { session } = await connect(smpp);
assert.ok(session);
const perSegment: string[] = [];
let merged = 0;
session.on('dlr', dlr => perSegment.push(dlr.smsId));
session.on('messageDlr', () => { merged++; });
const [sms] = await Promise.all([
incoming.then(async received => {
await received.sendResp({ smsId: 'unrequested' });
return received;
}),
session.sendSms({ from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }),
]);
await sms.sendDlr();
assert.deepEqual(perSegment, ['unrequested-1', 'unrequested-2', 'unrequested-3']);
assert.equal(merged, 0);
session.close();
await smpp.close();
});
});
describe('a session captured from Kannel', () => {
@@ -632,4 +787,297 @@ describe('robustness', () => {
session.close();
await smpp.close();
});
test('reports a response it could not send', async () => {
const sock = new net.Socket();
sock.destroy();
const session = new Session({ sock });
const failed = once<Error>(resolve => { session.on('sessionError', resolve); });
const sent = await session.sendReturn(enquireLink(7));
const reported = await raceWithin(500, failed);
assert.ok(sent.err instanceof Error);
assert.ok(reported instanceof Error, 'a response that never reached the wire should be reported');
assert.equal(reported.message, sent.err.message);
session.close();
});
test('ignores events from the socket it left behind on a reconnect', async () => {
const smpp = await startServer();
smpp.on('session', bound => {
bound.on('sms', sms => { void sms.sendResp(); });
});
const { session } = await connect(smpp, { reconnect: { maxDelay: 50, minDelay: 10 } });
assert.ok(session);
const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
const dead = session.sock;
for (const serverSession of smpp.sessions) {
serverSession.close();
}
await reconnected;
let closes = 0;
session.on('close', () => { closes++; });
dead.emit('close');
const sent = await session.sendSms({
from: '46701113311',
message: 'still up',
to: '46709771337',
});
assert.equal(closes, 0);
assert.equal(sent.err, undefined);
session.close();
await smpp.close();
});
test('closes the session when the signal aborts after the bind', async () => {
const smpp = await startServer();
const controller = new AbortController();
const { err, session } = await connect(smpp, { signal: controller.signal });
assert.equal(err, undefined);
assert.ok(session);
const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
controller.abort();
assert.ok(await closed);
await smpp.close();
});
});
describe('application hooks that throw', () => {
test('turns a throwing authenticate into a session error', async () => {
const smpp = await startServer({
authenticate: () => { throw new Error('authenticate exploded'); },
});
const failed = once<Error>(resolve => {
smpp.on('session', session => { session.on('sessionError', resolve); });
});
const { err } = await connect(smpp, { responseTimeout: 200 });
const reported = await raceWithin(500, failed);
assert.ok(err instanceof Error);
assert.ok(reported instanceof Error, 'a throwing authenticate should reach the session');
assert.equal(reported.message, 'authenticate exploded');
await smpp.close();
});
test('turns a throwing sms listener into a session error', async () => {
const smpp = await startServer();
const failed = once<Error>(resolve => {
smpp.on('session', session => {
session.on('sessionError', resolve);
session.on('sms', () => { throw new Error('listener exploded'); });
});
});
const { session } = await connect(smpp, { responseTimeout: 200 });
assert.ok(session);
const sent = await session.sendSms({
from: '46701113311',
message: 'blows up the listener',
to: '46709771337',
});
const reported = await raceWithin(500, failed);
assert.ok(sent.err instanceof Error);
assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session');
assert.equal(reported.message, 'listener exploded');
session.close();
await smpp.close();
});
test('keeps the reconnect loop alive when connect throws', async () => {
let attempts = 0;
const loop = new ReconnectLoop({
connect: () => {
attempts++;
throw new Error('connect exploded');
},
log: silentLog,
maxDelay: 10,
minDelay: 1,
onConnected: () => Promise.resolve({}),
});
loop.schedule();
const retried = await waitFor(() => attempts >= 2);
loop.stop();
assert.ok(retried, 'a throwing connect should be retried, not left for the process to die on');
});
test('starts only one reconnect attempt at a time', async () => {
let attempts = 0;
let finish: (() => void) | undefined;
const loop = new ReconnectLoop({
connect: () => {
attempts++;
return new Promise(resolve => {
finish = () => { resolve({ err: new Error('no socket') }); };
});
},
log: silentLog,
maxDelay: 5,
minDelay: 1,
onConnected: () => Promise.resolve({}),
});
loop.schedule();
assert.ok(await waitFor(() => attempts === 1));
// A second drop landing while the first attempt is still inside connect().
loop.schedule();
await delay(30);
assert.equal(attempts, 1);
loop.stop();
finish?.();
});
});
describe('link timers', () => {
test('closes a client link the peer has stopped answering', async () => {
const peer = await bindOnlyPeer();
const { err, session } = await client({ enquireLinkInterval: 50, port: peer.port });
assert.equal(err, undefined);
assert.ok(session);
const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
assert.ok(
await raceWithin(1000, closed),
'a peer that answers nothing should time the link out',
);
session.close();
await peer.close();
});
test('reconnects a link that timed out', async () => {
const peer = await bindOnlyPeer();
const { session } = await client({
enquireLinkInterval: 40,
port: peer.port,
reconnect: { maxDelay: 20, minDelay: 10 },
});
assert.ok(session);
const back = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
assert.ok(await raceWithin(2000, back), 'a link that timed out should be reconnected');
session.close();
await peer.close();
});
});
describe('merged delivery report bounds', () => {
function receipt(smsId: string): Dlr {
return {
doneDate: undefined,
errorCode: undefined,
receipt: undefined,
smsId,
statusId: 2,
statusMsg: 'DELIVERED',
};
}
function merger(options: { max?: number; now?: () => number } = {}): DlrMerger {
return new DlrMerger({
log: silentLog,
max: options.max ?? 10,
now: options.now ?? (() => 0),
timeout: 60,
});
}
test('merges the receipts of one message and forgets the group', () => {
const dlrMerger = merger();
dlrMerger.expect(['whole-1', 'whole-2']);
assert.equal(dlrMerger.collect(receipt('whole-1')), undefined);
const merged = dlrMerger.collect(receipt('whole-2'));
assert.ok(merged);
assert.equal(merged.smsId, 'whole');
assert.equal(merged.segments.length, 2);
assert.equal(dlrMerger.size, 0);
});
// Every multipart send registered a group, and only a complete set of receipts ever removed it.
test('drops the oldest group once the cap is reached', () => {
const dlrMerger = merger({ max: 2 });
for (const base of ['first', 'second', 'third']) {
dlrMerger.expect([`${base}-1`, `${base}-2`]);
}
assert.equal(dlrMerger.size, 2);
assert.equal(dlrMerger.collect(receipt('first-1')), undefined);
assert.equal(dlrMerger.collect(receipt('first-2')), undefined);
dlrMerger.clear();
assert.equal(dlrMerger.size, 0);
});
test('expires a group whose receipts never all arrived', () => {
let now = 0;
const dlrMerger = merger({ now: () => now });
dlrMerger.expect(['late-1', 'late-2']);
now = 61;
assert.equal(dlrMerger.collect(receipt('late-1')), undefined);
assert.equal(dlrMerger.size, 0);
});
});
describe('option validation', () => {
test('refuses an interface version that cannot go on the wire', async () => {
const { err, server: smpp } = await server({ interfaceVersion: 0x100, port: 0 });
if (smpp) await smpp.close();
assert.ok(err instanceof Error);
});
test('returns an error rather than rejecting on an impossible port', async () => {
const listening = await server({ port: 70_000 });
assert.ok(listening.err instanceof Error);
const connected = await client({ port: 70_000 });
assert.ok(connected.err instanceof Error);
});
});
+18 -12
View File
@@ -142,8 +142,7 @@ describe('tls', () => {
const [sms, sent] = await Promise.all([
incoming.then(async received => {
received.smsId = 'tls-id';
await received.sendResp();
await received.sendResp({ smsId: 'tls-id' });
return received;
}),
@@ -193,22 +192,29 @@ describe('tls', () => {
});
test('logs a handshake the server turned away', async () => {
const warned = once<string>(resolve => {
const log = new Log({ logLevel: 'warn', stderr: resolve, stdout: resolve });
let onWarning: ((message: string) => void) | undefined;
const warned = once<string>(resolve => { onWarning = resolve; });
const log = new Log({
logLevel: 'warn',
stderr: message => onWarning?.(message),
stdout: message => onWarning?.(message),
});
const { err, server: smpp } = await server({
log,
port: 0,
tls: { cert: certificate.cert, key: certificate.key },
});
void server({ log, port: 0, tls: { cert: certificate.cert, key: certificate.key } })
.then(({ server: smpp }) => {
assert.equal(err, undefined);
assert.ok(smpp);
const sock = net.connect({ port: smpp.port }, () => {
sock.end('not a client hello');
});
const sock = net.connect({ port: smpp.port }, () => { sock.end('not a client hello'); });
sock.on('close', () => { void smpp.close(); });
sock.resume();
});
});
assert.match(await warned, /client handshake failed/);
sock.destroy();
await smpp.close();
});
});
+20
View File
@@ -155,6 +155,14 @@ describe('dest_address_array', () => {
assert.deepEqual(target, encoded);
});
test('refuses a field value the wire cannot hold instead of throwing', () => {
const badTon: DestAddress[] = [{ dest_addr_npi: 0, dest_addr_ton: 999, destination_addr: '123' }];
const tooMany: DestAddress[] = Array.from({ length: 300 }, () => ({ dl_name: 'a' }));
assert.ok(types.dest_address_array.write(badTon, Buffer.alloc(8), 0).err instanceof Error);
assert.ok(types.dest_address_array.write(tooMany, Buffer.alloc(901), 0).err instanceof Error);
});
});
describe('unsuccess_sme_array', () => {
@@ -186,6 +194,18 @@ describe('unsuccess_sme_array', () => {
assert.deepEqual(target, encoded);
});
test('refuses a field value the wire cannot hold instead of throwing', () => {
const badStatus: UnsuccessSme[] = [
{ dest_addr_npi: 0, dest_addr_ton: 0, destination_addr: 'abc', error_status_code: 0x1FFFFFFFF },
];
const badTon: UnsuccessSme[] = [
{ dest_addr_npi: 0, dest_addr_ton: 999, destination_addr: 'abc', error_status_code: 0 },
];
assert.ok(types.unsuccess_sme_array.write(badStatus, Buffer.alloc(11), 0).err instanceof Error);
assert.ok(types.unsuccess_sme_array.write(badTon, Buffer.alloc(11), 0).err instanceof Error);
});
});
describe('bounds checking', () => {
+1 -1
View File
@@ -5,7 +5,7 @@ rules there constrain every item below.
## Status
The rewrite is **feature complete and green**: 155 tests, lint and typecheck clean, verified on Node
The rewrite is **feature complete and green**: 184 tests, lint and typecheck clean, verified on Node
18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
```bash