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
+46 -26
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,
+82 -37
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;
}
private dropOldest(): void {
const oldest = this.groups.keys().next();
if (oldest.done) return;
this.log.warn('reassembler - buffer full, dropping the oldest message', { max: this.max });
this.groups.delete(oldest.value);
/** 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 idle(): void {
if (!this.sweeper || this.groups.size > 0) return;
private dropOldest(): void {
const oldest = this.groups.takeOldest();
clearInterval(this.sweeper);
this.sweeper = undefined;
if (!oldest) return;
const [, group] = oldest;
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;
}
}
+26 -8
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;
pduObjs.push(one.pduObj);
smsIds.push(paramText(one.pduObj.params.message_id));
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 };
}
+26 -9
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);
listener.listen(port, options.host, () => {
listener.removeListener('error', onStartupError);
onListening(listener, smpp, options);
resolve({ server: smpp });
});
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) ?? {};