Add session, client and server with the promise result API

This commit is contained in:
2026-08-25 16:46:32 +02:00
parent 1827ab5964
commit fd2f98464f
11 changed files with 1819 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
import type { ConnectionOptions } from 'node:tls';
import type { LogInt } from '@larvit/log';
import type { Result, VoidResult } from './result.ts';
import type { Socket } from 'node:net';
import { Session } from './session.ts';
import { connect as netConnect } from 'node:net';
import { connect as tlsConnect } from 'node:tls';
import { silentLog } from './log.ts';
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
export type ClientOptions = {
addressRange?: string;
addrNpi?: number;
addrTon?: number;
bindType?: BindType;
enquireLinkInterval?: number;
host?: string;
interfaceVersion?: number;
log?: LogInt;
maxOutstanding?: number;
password?: string;
port?: number;
reconnect?: { maxDelay?: number; minDelay?: number };
responseTimeout?: number;
signal?: AbortSignal;
systemType?: string;
tls?: ConnectionOptions | boolean;
username?: string;
};
const defaults = {
bindType: 'transceiver',
enquireLinkInterval: 20_000,
host: 'localhost',
/** SMPP 5.0. 0.4.0 declared 0x00 because its per-parameter default was never applied. */
interfaceVersion: 0x50,
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 }>> {
return new Promise(resolve => {
const host = options.host ?? defaults.host;
const port = options.port ?? defaults.port;
const signal = options.signal;
if (signal?.aborted === true) {
resolve({ err: new Error('Aborted before connecting') });
return;
}
const sock = options.tls === undefined || options.tls === false
? netConnect({ host, port })
: tlsConnect({
host,
port,
...(typeof options.tls === 'object' ? options.tls : {}),
});
const settle = (result: Result<{ sock: Socket }>): void => {
sock.removeListener('error', onError);
signal?.removeEventListener('abort', onAbort);
resolve(result);
};
function onError(err: Error): void {
settle({ err });
}
function onAbort(): void {
sock.destroy();
settle({ err: new Error('Aborted while connecting') });
}
sock.once('error', onError);
signal?.addEventListener('abort', onAbort, { once: true });
sock.once(options.tls === undefined || options.tls === false ? 'connect' : 'secureConnect', () => {
settle({ sock });
});
});
}
async function bind(session: Session, options: ClientOptions): Promise<VoidResult> {
const bindType = options.bindType ?? defaults.bindType;
const systemId = options.username ?? defaults.username;
const sent = await session.send({
cmdName: `bind_${bindType}`,
params: {
address_range: options.addressRange ?? '',
addr_npi: options.addrNpi ?? 0,
addr_ton: options.addrTon ?? 0,
interface_version: options.interfaceVersion ?? defaults.interfaceVersion,
password: options.password ?? defaults.password,
system_id: systemId,
system_type: options.systemType ?? '',
},
...(options.signal ? { signal: options.signal } : {}),
});
if (sent.err) return { err: sent.err };
if (sent.pduObj.cmdStatus !== 'ESME_ROK') {
session.log.info('client - bind refused', {
cmdStatus: sent.pduObj.cmdStatus ?? sent.pduObj.cmdStatusId,
systemId,
});
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
}
session.loggedIn = true;
session.log.info('client - bound', { bindType, systemId });
return {};
}
/** Connects to an SMSC and binds. */
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
const log = options.log ?? silentLog;
const opened = await openSocket(options);
if (opened.err) {
log.warn('client - could not connect', {
host: options.host ?? defaults.host,
message: opened.err.message,
port: options.port ?? defaults.port,
});
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 bound = await bind(session, options);
if (bound.err) {
session.close();
return { err: bound.err };
}
if (options.signal) {
options.signal.addEventListener('abort', () => { session.close(); }, { once: true });
}
return { session };
}
+19
View File
@@ -0,0 +1,19 @@
import { cmds, cmdsById } from './commands.ts';
import { consts, constsById } from './constants.ts';
import { encodings } from './encodings.ts';
import { errors, errorsById } from './errors.ts';
import { tlvs, tlvsById } from './tlvs.ts';
import { types } from './types.ts';
export const defs = {
cmds,
cmdsById,
consts,
constsById,
encodings,
errors,
errorsById,
tlvs,
tlvsById,
types,
};
+9
View File
@@ -24,6 +24,15 @@ export type WireType<T extends ParamValue = ParamValue> = {
write: (value: ParamValue, buffer: Buffer, offset: number) => VoidResult; write: (value: ParamValue, buffer: Buffer, offset: number) => VoidResult;
}; };
/** Renders a parameter as text without ever falling back to "[object Object]". */
export function paramText(value: ParamValue | undefined): string {
if (typeof value === 'string') return value;
if (typeof value === 'number') return value.toString();
if (Buffer.isBuffer(value)) return value.toString('ascii');
return '';
}
function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined { function outOfRange(buffer: Buffer, offset: number, needed: number): Error | undefined {
if (offset < 0 || needed < 0 || offset + needed > buffer.length) { if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
return new Error( return new Error(
+65
View File
@@ -0,0 +1,65 @@
export { client } from './client.ts';
export { server, SmppServer } from './server.ts';
export { Session } from './session.ts';
export { cmds, cmdsById, commandNameById, isCommandName } from './defs/commands.ts';
export { consts, constsById } from './defs/constants.ts';
export { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
export { errorNameById, errors, errorsById, isErrorName } from './defs/errors.ts';
export { tlvs, tlvsById } from './defs/tlvs.ts';
export { types } from './defs/types.ts';
export {
isCommand,
isResp,
maxPduLength,
maxSeqNr,
objToPdu,
pduReturn,
pduToObj,
} from './pdu.ts';
export {
bitCount,
decodeMessage,
encodeMessage,
smppDate,
smppTime,
splitMessage,
} from './message.ts';
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
export { concatInfo } from './udh.ts';
export { PduFramer } from './pdu-framer.ts';
export { uuidv7 } from './uuid.ts';
export type { BindType, ClientOptions } from './client.ts';
export type { Dlr, Receipt } from './dlr.ts';
export type { Sms, SmsInput } from './sms.ts';
export type { ConcatInfo } from './udh.ts';
export type { Result, VoidResult } from './result.ts';
export type {
AuthenticateInput,
AuthenticateResult,
ServerEvents,
ServerOptions,
} from './server.ts';
export type {
MessageDlr,
ReconnectOptions,
SendOptions,
SendSmsOptions,
SessionEvents,
SessionOptions,
} from './session.ts';
export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts';
export type { ConstGroup, MessageState } from './defs/constants.ts';
export type { Encoding, EncodingName } from './defs/encodings.ts';
export type { ErrorName } from './defs/errors.ts';
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
export type { SplitOptions } from './message.ts';
export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts';
export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.ts';
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
export { defs } from './defs/index.ts';
+5
View File
@@ -0,0 +1,5 @@
import type { LogInt } from '@larvit/log';
import { Log } from '@larvit/log';
/** The default: a library that says nothing unless the application asks it to. */
export const silentLog: LogInt = new Log({ logLevel: 'none' });
+202
View File
@@ -0,0 +1,202 @@
import type { LogInt } from '@larvit/log';
import type { PduObject } from './pdu.ts';
import type { Result } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net';
import type { TlsOptions } from 'node:tls';
import { EventEmitter } from 'node:events';
import { Session } from './session.ts';
import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls';
import { paramText } from './defs/types.ts';
import { silentLog } from './log.ts';
export type AuthenticateResult = { userData?: unknown } | boolean;
export type AuthenticateInput = {
password: string;
session: Session;
systemId: string;
systemType: string;
};
export type ServerOptions = {
authenticate?: (input: AuthenticateInput) => Promise<AuthenticateResult> | AuthenticateResult;
host?: string;
idleTimeout?: number;
log?: LogInt;
maxOutstanding?: number;
maxReassembly?: number;
port?: number;
reassemblyTimeout?: number;
responseTimeout?: number;
signal?: AbortSignal;
tls?: TlsOptions | boolean;
};
export type ServerEvents = {
serverError: [Error];
session: [Session];
};
const defaults = {
idleTimeout: 40_000,
port: 2775,
};
const bindCommands = ['bind_receiver', 'bind_transceiver', 'bind_transmitter'];
/** A listening SMPP server. Sessions arrive as `session` events; `close()` stops listening. */
export class SmppServer extends EventEmitter<ServerEvents> {
readonly sessions = new Set<Session>();
private readonly server: NetServer;
constructor(server: NetServer) {
super();
this.server = server;
}
/** The port actually bound, which matters when 0 was requested. */
get port(): number {
const address = this.server.address();
return typeof address === 'object' && address !== null ? address.port : 0;
}
/** Stops listening and closes every live session. */
close(): Promise<void> {
return new Promise(resolve => {
for (const session of this.sessions) {
session.close();
}
this.sessions.clear();
this.server.close(() => { resolve(); });
});
}
}
async function authenticate(
session: Session,
pduObj: PduObject,
options: ServerOptions,
): Promise<boolean> {
if (!options.authenticate) return true;
const params = pduObj.params;
const result = await options.authenticate({
password: typeof params.password === 'string' ? params.password : '',
session,
systemId: typeof params.system_id === 'string' ? params.system_id : '',
systemType: typeof params.system_type === 'string' ? params.system_type : '',
});
if (result === false) return false;
if (result !== true && typeof result === 'object') {
session.userData = result.userData;
}
return true;
}
/**
* Handles everything a peer may send before it is bound. Returns true when it has answered, so the
* session leaves the PDU alone.
*/
async function onRequest(
session: Session,
pduObj: PduObject,
options: ServerOptions,
): Promise<boolean> {
const log = options.log ?? silentLog;
if (session.loggedIn || pduObj.cmdName === 'unbind') return false;
if (!bindCommands.includes(pduObj.cmdName)) {
log.debug('server - command before bind', { cmdName: pduObj.cmdName });
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
return true;
}
if (!await authenticate(session, pduObj, options)) {
log.info('server - bind refused', { systemId: paramText(pduObj.params.system_id) });
await session.sendReturn(pduObj, 'ESME_RBINDFAIL');
return true;
}
session.loggedIn = true;
await session.sendReturn(pduObj);
log.verbose('server - bound', { systemId: paramText(pduObj.params.system_id) });
return true;
}
function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): void {
const log = options.log ?? silentLog;
const session = new Session({
idleTimeout: options.idleTimeout ?? defaults.idleTimeout,
log,
maxOutstanding: options.maxOutstanding,
maxReassembly: options.maxReassembly,
onRequest: (bound, pduObj) => onRequest(bound, pduObj, options),
reassemblyTimeout: options.reassemblyTimeout,
responseTimeout: options.responseTimeout,
sock,
});
server.sessions.add(session);
session.on('close', () => server.sessions.delete(session));
log.verbose('server - incoming connection', {
remoteAddress: sock.remoteAddress ?? '',
remotePort: sock.remotePort ?? 0,
});
server.emit('session', session);
}
/** Starts listening for SMPP connections. Resolves once the socket is bound. */
export function server(options: ServerOptions = {}): Promise<Result<{ server: SmppServer }>> {
return new Promise(resolve => {
const log = options.log ?? silentLog;
const port = options.port ?? defaults.port;
const useTls = options.tls !== undefined && options.tls !== false;
const listener = useTls
? createTlsServer(typeof options.tls === 'object' ? options.tls : {})
: createNetServer();
const smpp = new SmppServer(listener);
listener.on(useTls ? 'secureConnection' : 'connection', (sock: Socket) => {
onConnection(sock, options, smpp);
});
const onStartupError = (err: Error): void => {
listener.removeListener('error', onStartupError);
log.warn('server - could not listen', { message: err.message, port });
resolve({ err });
};
listener.once('error', onStartupError);
listener.listen(port, options.host, () => {
listener.removeListener('error', onStartupError);
// Past startup, a listener error is a runtime event, not a failed start.
listener.on('error', (err: Error) => {
log.warn('server - error', { message: err.message });
smpp.emit('serverError', err);
});
log.info('server - listening', { host: options.host ?? '*', port: smpp.port });
if (options.signal) {
options.signal.addEventListener('abort', () => { void smpp.close(); }, { once: true });
}
resolve({ server: smpp });
});
});
}
+738
View File
@@ -0,0 +1,738 @@
import type { Dlr } from './dlr.ts';
import type { EncodingName } from './defs/encodings.ts';
import type { ErrorName } from './defs/errors.ts';
import type { LogInt } from '@larvit/log';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Sms } from './sms.ts';
import type { Socket } from 'node:net';
import { EventEmitter } from 'node:events';
import { PduFramer } from './pdu-framer.ts';
import { concatInfo } from './udh.ts';
import { consts } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { decodeMessage, smppTime, splitMessage } from './message.ts';
import { detect } from './defs/encodings.ts';
import { dlrFromPdu } from './dlr.ts';
import { isResp, maxSeqNr, objToPdu, pduReturn, pduToObj } from './pdu.ts';
import { paramText } from './defs/types.ts';
import { silentLog } from './log.ts';
export type MessageDlr = Dlr & { segments: Dlr[] };
export type SessionEvents = {
close: [];
data: [Buffer];
dlr: [Dlr, PduObject];
incomingPdu: [Buffer];
incomingPduObj: [PduObject];
messageDlr: [MessageDlr];
reconnected: [];
sessionError: [Error];
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 SendSmsOptions = {
dlr?: boolean;
destinationAddrNpi?: number;
destinationAddrTon?: number;
encoding?: EncodingName;
flash?: boolean;
from: string;
message: string;
scheduleDeliveryTime?: Date | number | string;
sourceAddrNpi?: number;
sourceAddrTon?: number;
to: string;
validityPeriod?: Date | number | string;
};
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;
};
type Pending = {
settle: (result: Result<{ pduObj: PduObject }>) => void;
};
type Reassembly = {
parts: Map<number, PduObject>;
timer: NodeJS.Timeout;
total: number;
};
const defaults = {
maxDelay: 30_000,
maxOutstanding: 10,
maxReassembly: 1000,
minDelay: 1000,
reassemblyTimeout: 300_000,
responseTimeout: 30_000,
};
/** Alphanumeric senders must be TON 5; 0.4.0 sent everything as TON 1 (international). */
function addressTon(address: string): number {
return /^\+?\d+$/.test(address) ? consts.TON.INTERNATIONAL : consts.TON.ALPHANUMERIC;
}
function dataCodingFor(encoding: EncodingName, flash: boolean): number {
if (!flash) return consts.ENCODING[encoding];
// Message class present (0x10) plus the alphabet bits, so flash survives UCS2.
return encoding === 'UCS2' ? 0x18 : 0x10;
}
export class Session extends EventEmitter<SessionEvents> {
/** Replaced on reconnect, so hold the session rather than this. */
sock: Socket;
readonly log: LogInt;
loggedIn = false;
userData: unknown = undefined;
private framer = new PduFramer();
private readonly options: SessionOptions;
private readonly pending = new Map<number, Pending>();
private readonly reassembly = new Map<string, Reassembly>();
private readonly segmentDlrs = new Map<string, Map<number, Dlr>>();
private readonly waiting: (() => void)[] = [];
private closed = false;
private concatReference = 0;
private enquireLinkTimer: NodeJS.Timeout | undefined;
private idleTimer: NodeJS.Timeout | undefined;
private inFlight = 0;
private ourSeqNr = 1;
private reconnectDelay: number;
private reconnectTimer: NodeJS.Timeout | undefined;
private stopped = false;
constructor(options: SessionOptions) {
super();
this.options = options;
this.log = options.log ?? silentLog;
this.sock = options.sock;
this.reconnectDelay = options.reconnect?.minDelay ?? defaults.minDelay;
this.attach(options.sock);
this.resetTimers();
}
/** Wires a freshly opened socket into this session, replacing any previous one. */
private attach(sock: Socket): void {
this.sock = sock;
this.framer = new PduFramer();
this.closed = false;
sock.on('data', chunk => { this.onData(chunk); });
sock.on('close', () => { this.onClose(); });
sock.on('error', err => {
this.log.warn('session - socket error', { message: err.message });
this.emit('sessionError', err);
this.onClose();
});
}
/** Sends a request and resolves with the peer's response. */
async send(
input: PduObjectInput,
options: SendOptions = {},
): Promise<Result<{ pduObj: PduObject }>> {
if (input.cmdName.endsWith('_resp')) {
return { err: new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) };
}
if (this.closed) return { err: new Error('Session is closed') };
await this.acquire();
try {
const seqNr = this.nextSeqNr();
const built = objToPdu({ ...input, seqNr });
if (built.err) return { err: built.err };
const response = this.awaitResponse(seqNr, options.signal);
const written = this.write(built.buffer);
if (written.err) {
this.settle(seqNr, { err: written.err });
return { err: written.err };
}
return await response;
} finally {
this.release();
}
}
/** Answers a request the peer sent us. Responses are never waited on. */
async sendReturn(
pdu: PduObject,
status: ErrorName = 'ESME_ROK',
params: Record<string, ParamValue> = {},
tlvs?: Record<string, TlvInput>,
): Promise<VoidResult> {
const built = pduReturn(pdu, status, params, tlvs);
if (built.err) return { err: built.err };
return Promise.resolve(this.write(built.buffer));
}
async sendSms(
sms: SendSmsOptions,
options: SendOptions = {},
): Promise<Result<{ pduObjs: PduObject[]; smsIds: string[] }>> {
const encoding = sms.encoding ?? detect(sms.message);
const segments = splitMessage(sms.message, {
encoding,
reference: this.nextConcatReference(),
});
const pduObjs: PduObject[] = [];
const smsIds: string[] = [];
this.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to });
// Segments go out together rather than one-after-a-response: a receiver that waits for every
// segment before answering — this library's own server does — would otherwise deadlock.
const sent = await Promise.all(segments.map(segment => {
const params: Record<string, ParamValue> = {
data_coding: dataCodingFor(encoding, sms.flash === true),
destination_addr: sms.to,
dest_addr_npi: sms.destinationAddrNpi ?? 0,
dest_addr_ton: sms.destinationAddrTon ?? addressTon(sms.to),
short_message: segment,
sm_length: segment.length,
source_addr: sms.from,
source_addr_npi: sms.sourceAddrNpi ?? 0,
source_addr_ton: sms.sourceAddrTon ?? addressTon(sms.from),
};
if (segments.length > 1) params.esm_class = consts.ESM_CLASS.UDH_INDICATOR;
if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL;
if (sms.validityPeriod !== undefined) {
params.validity_period = smppTime.encode(sms.validityPeriod);
}
if (sms.scheduleDeliveryTime !== undefined) {
params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime);
}
return this.send({ cmdName: 'submit_sm', params }, options);
}));
for (const one of sent) {
if (one.err) return { err: one.err };
pduObjs.push(one.pduObj);
smsIds.push(paramText(one.pduObj.params.message_id));
}
return { pduObjs, smsIds };
}
/** Unbinds politely, then closes. */
async unbind(): Promise<VoidResult> {
const sent = await this.send({ cmdName: 'unbind' });
this.close();
return sent.err ? { err: sent.err } : {};
}
/** Closes for good. A session closed this way never reconnects. */
close(): void {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
this.teardown();
}
private teardown(): void {
if (this.closed) return;
this.closed = true;
this.clearTimers();
for (const [seqNr] of this.pending) {
this.settle(seqNr, { err: new Error('Session closed before a response arrived') });
}
for (const group of this.reassembly.values()) {
clearTimeout(group.timer);
}
this.reassembly.clear();
this.sock.destroy();
}
private nextSeqNr(): number {
const seqNr = this.ourSeqNr;
this.ourSeqNr = this.ourSeqNr >= maxSeqNr ? 1 : this.ourSeqNr + 1;
return seqNr;
}
private nextConcatReference(): number {
this.concatReference = this.concatReference >= 255 ? 1 : this.concatReference + 1;
return this.concatReference;
}
private async acquire(): Promise<void> {
const limit = this.options.maxOutstanding ?? defaults.maxOutstanding;
if (this.inFlight < limit) {
this.inFlight++;
return;
}
return new Promise<void>(resolve => this.waiting.push(resolve));
}
private release(): void {
const next = this.waiting.shift();
if (next) {
next();
return;
}
this.inFlight--;
}
private write(pdu: Buffer): VoidResult {
if (this.sock.destroyed) {
return { err: new Error('Socket is closed') };
}
this.sock.write(pdu);
return {};
}
private awaitResponse(
seqNr: number,
signal: AbortSignal | undefined,
): Promise<Result<{ pduObj: PduObject }>> {
return new Promise(resolve => {
const timeout = this.options.responseTimeout ?? defaults.responseTimeout;
let timer: NodeJS.Timeout | undefined;
const onAbort = (): void => {
this.settle(seqNr, { err: new Error('Aborted before a response arrived') });
};
const settle = (result: Result<{ pduObj: PduObject }>): void => {
if (timer) clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
this.pending.delete(seqNr);
resolve(result);
};
this.pending.set(seqNr, { settle });
if (signal?.aborted === true) {
settle({ err: new Error('Aborted before a response arrived') });
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
if (timeout > 0) {
timer = setTimeout(() => {
this.log.warn('session - no response before the timeout', { seqNr, timeout });
this.settle(seqNr, { err: new Error(`No response to seqNr ${String(seqNr)}`) });
}, timeout);
timer.unref();
}
});
}
private settle(seqNr: number, result: Result<{ pduObj: PduObject }>): void {
this.pending.get(seqNr)?.settle(result);
}
private onData(chunk: Buffer): void {
this.emit('data', chunk);
this.resetTimers();
this.framer.push(chunk);
const framed = this.framer.next();
if (framed.err) {
this.log.warn('session - unusable stream, closing', { message: framed.err.message });
this.emit('sessionError', framed.err);
this.close();
return;
}
for (const pdu of framed.pdus) {
this.emit('incomingPdu', pdu);
const parsed = pduToObj(pdu);
if (parsed.err) {
this.log.warn('session - could not parse an incoming PDU, closing', {
message: parsed.err.message,
});
this.emit('sessionError', parsed.err);
this.close();
return;
}
this.dispatch(parsed.pduObj);
}
}
private dispatch(pduObj: PduObject): void {
if (isResp(pduObj)) {
const pending = this.pending.get(pduObj.seqNr);
if (!pending) {
this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr });
return;
}
pending.settle({ pduObj });
return;
}
this.emit('incomingPduObj', pduObj);
void this.handle(pduObj);
}
private async handle(pduObj: PduObject): Promise<void> {
const onRequest = this.options.onRequest;
if (onRequest && await onRequest(this, pduObj)) return;
if (pduObj.cmdName === 'enquire_link') {
await this.sendReturn(pduObj);
return;
}
if (pduObj.cmdName === 'unbind') {
await this.sendReturn(pduObj);
this.close();
return;
}
if (pduObj.cmdName === 'submit_sm') {
this.onSubmitSm(pduObj);
return;
}
if (pduObj.cmdName === 'deliver_sm') {
await this.onDeliverSm(pduObj);
return;
}
this.log.info('session - no handler for command', { cmdName: pduObj.cmdName });
await this.sendReturn(pduObj, 'ESME_RINVCMDID');
}
private onSubmitSm(pduObj: PduObject): void {
const message = pduObj.params.short_message;
const esmClass = pduObj.params.esm_class;
const hasUdh = typeof esmClass === 'number'
&& (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR;
if (!hasUdh || !Buffer.isBuffer(message)) {
this.emitSms([pduObj]);
return;
}
const concat = concatInfo(message);
if (!concat) {
this.emitSms([pduObj]);
return;
}
this.collectSegment(pduObj, concat);
}
private collectSegment(
pduObj: PduObject,
concat: { part: number; reference: number; total: number },
): void {
const key = [
paramText(pduObj.params.source_addr),
paramText(pduObj.params.destination_addr),
String(concat.reference),
].join('_');
let group = this.reassembly.get(key);
if (!group) {
const limit = this.options.maxReassembly ?? defaults.maxReassembly;
if (this.reassembly.size >= limit) {
const oldest = this.reassembly.keys().next();
if (!oldest.done) {
this.log.warn('session - reassembly buffer full, dropping the oldest message', {
limit,
});
this.dropReassembly(oldest.value);
}
}
const timer = setTimeout(() => {
this.log.info('session - incomplete message expired', { key, total: concat.total });
this.dropReassembly(key);
}, this.options.reassemblyTimeout ?? defaults.reassemblyTimeout);
timer.unref();
group = { parts: new Map(), timer, total: concat.total };
this.reassembly.set(key, group);
}
group.parts.set(concat.part, pduObj);
if (group.parts.size < group.total) return;
clearTimeout(group.timer);
this.reassembly.delete(key);
const ordered = [...group.parts.entries()]
.sort(([a], [b]) => a - b)
.map(([, part]) => part);
this.emitSms(ordered);
}
private dropReassembly(key: string): void {
const group = this.reassembly.get(key);
if (!group) return;
clearTimeout(group.timer);
this.reassembly.delete(key);
}
private emitSms(pduObjs: PduObject[]): void {
const first = pduObjs[0];
if (!first) return;
let message = '';
for (const pduObj of pduObjs) {
const part = pduObj.params.short_message;
const dataCoding = pduObj.params.data_coding;
const esmClass = pduObj.params.esm_class;
message += Buffer.isBuffer(part)
? decodeMessage(
part,
typeof dataCoding === 'number' ? dataCoding : 0,
typeof esmClass === 'number' ? esmClass : 0,
).message
: paramText(part);
}
this.emit('sms', createSms({
from: paramText(first.params.source_addr),
message,
pduObjs,
session: this,
to: paramText(first.params.destination_addr),
}));
}
private async onDeliverSm(pduObj: PduObject): Promise<void> {
const dlr = dlrFromPdu(pduObj);
if (!dlr) {
this.log.info('session - deliver_sm carries no delivery report', { seqNr: pduObj.seqNr });
await this.sendReturn(pduObj, 'ESME_RINVTLVSTREAM');
return;
}
this.emit('dlr', dlr, pduObj);
this.collectSegmentDlr(dlr);
await this.sendReturn(pduObj);
}
/** Segment ids look like `<uuid>-<n>`; once every segment is in, report on the whole message. */
private collectSegmentDlr(dlr: Dlr): void {
const match = /^(.*)-(\d+)$/.exec(dlr.smsId);
if (!match) return;
const [, smsId, part] = match;
if (smsId === undefined || part === undefined) return;
const segments = this.segmentDlrs.get(smsId) ?? new Map<number, Dlr>();
segments.set(Number(part), dlr);
this.segmentDlrs.set(smsId, segments);
const highest = Math.max(...segments.keys());
if (segments.size < highest) return;
this.segmentDlrs.delete(smsId);
const ordered = [...segments.entries()].sort(([a], [b]) => a - b).map(([, one]) => one);
const worst = ordered.reduce((carry, one) => (one.statusId > carry.statusId ? one : carry));
this.emit('messageDlr', { ...worst, segments: ordered, smsId });
}
private resetTimers(): void {
if (this.closed) return;
this.clearTimers();
const { enquireLinkInterval, idleTimeout } = this.options;
if (enquireLinkInterval !== undefined && enquireLinkInterval > 0) {
this.enquireLinkTimer = setTimeout(() => {
void this.send({ cmdName: 'enquire_link' });
}, enquireLinkInterval);
this.enquireLinkTimer.unref();
}
if (idleTimeout !== undefined && idleTimeout > 0) {
this.idleTimer = setTimeout(() => {
this.log.info('session - closing an idle peer', { idleTimeout });
this.close();
}, idleTimeout);
this.idleTimer.unref();
}
}
private clearTimers(): void {
if (this.enquireLinkTimer) clearTimeout(this.enquireLinkTimer);
if (this.idleTimer) clearTimeout(this.idleTimer);
this.enquireLinkTimer = undefined;
this.idleTimer = undefined;
}
private onClose(): void {
const wasOpen = !this.closed;
this.teardown();
if (wasOpen) this.emit('close');
if (!this.stopped && this.options.reconnect) this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
const reconnect = this.options.reconnect;
if (!reconnect) return;
const delay = this.reconnectDelay;
this.log.info('session - reconnecting after a drop', { delay });
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
void this.reconnect();
}, delay);
this.reconnectTimer.unref();
this.reconnectDelay = Math.min(delay * 2, reconnect.maxDelay ?? defaults.maxDelay);
}
/** Read through a method: close() can land while a reconnect is awaiting. */
private isStopped(): boolean {
return this.stopped;
}
private async reconnect(): Promise<void> {
const reconnect = this.options.reconnect;
if (!reconnect || this.isStopped()) return;
const opened = await reconnect.connect();
if (opened.err) {
this.log.warn('session - reconnect failed to open a socket', {
message: opened.err.message,
});
this.scheduleReconnect();
return;
}
if (this.isStopped()) {
opened.sock.destroy();
return;
}
this.attach(opened.sock);
const bound = await reconnect.onConnected(this);
if (bound.err) {
this.log.warn('session - reconnect failed to bind', { message: bound.err.message });
this.teardown();
this.scheduleReconnect();
return;
}
this.reconnectDelay = reconnect.minDelay ?? defaults.minDelay;
this.resetTimers();
this.log.info('session - reconnected');
this.emit('reconnected');
}
}
+129
View File
@@ -0,0 +1,129 @@
import type { ErrorName } from './defs/errors.ts';
import type { MessageState } from './defs/constants.ts';
import type { PduObject } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
import { consts } from './defs/constants.ts';
import { receiptCodes } from './dlr.ts';
import { smppDate } from './message.ts';
import { uuidv7 } from './uuid.ts';
/**
* A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying
* every segment's PDU.
*/
export type Sms = {
dlr: boolean;
flash: boolean;
from: string;
message: string;
pduObjs: PduObject[];
/** 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>;
session: Session;
/** Generated as a UUID v7 unless the application sets its own before answering. */
smsId: string;
submitTime: Date;
to: string;
};
export type SmsInput = {
from: string;
message: string;
pduObjs: PduObject[];
session: Session;
to: string;
};
/** Each segment of a multipart message gets its own message_id, as a separate submit_sm must. */
function segmentId(smsId: string, index: number, total: number): string {
return total === 1 ? smsId : `${smsId}-${String(index + 1)}`;
}
export function createSms(input: SmsInput): Sms {
const first = input.pduObjs[0];
const registered = first?.params.registered_delivery;
const dataCoding = first?.params.data_coding;
const sms: Sms = {
dlr: typeof registered === 'number' && registered !== 0,
flash: typeof dataCoding === 'number' && (dataCoding & 0xF0) === 0x10,
from: input.from,
message: input.message,
pduObjs: input.pduObjs,
sendDlr: status => sendDlr(sms, status),
sendResp: status => sendResp(sms, status),
session: input.session,
smsId: uuidv7(),
submitTime: new Date(),
to: input.to,
};
return sms;
}
async function sendResp(sms: Sms, status: ErrorName = 'ESME_ROK'): Promise<VoidResult> {
const total = sms.pduObjs.length;
if (total === 0) {
return { err: new Error('No PDUs to answer') };
}
const results = await Promise.all(sms.pduObjs.map((pduObj, index) => sms.session.sendReturn(
pduObj,
status,
{ message_id: segmentId(sms.smsId, index, total) },
)));
return results.find(result => result.err) ?? {};
}
async function sendDlr(
sms: Sms,
status: MessageState = 'DELIVERED',
): Promise<Result<{ pduObjs: PduObject[] }>> {
const statusId = consts.MESSAGE_STATE[status];
const total = sms.pduObjs.length;
const pduObjs: PduObject[] = [];
for (let index = 0; index < total; index++) {
const smsId = segmentId(sms.smsId, index, total);
const delivered = status === 'DELIVERED';
const message = [
`id:${smsId}`,
'sub:001',
`dlvrd:${delivered ? '001' : '000'}`,
`submit date:${smppDate(sms.submitTime)}`,
`done date:${smppDate(new Date())}`,
`stat:${receiptCodes[status]}`,
`err:${delivered ? '000' : '001'}`,
'text:',
].join(' ');
const sent = await sms.session.send({
cmdName: 'deliver_sm',
params: {
destination_addr: sms.from,
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: message,
source_addr: sms.to,
},
tlvs: {
message_state: { tagId: 0x0427, tagName: 'message_state', tagValue: statusId },
receipted_message_id: {
tagId: 0x001E,
tagName: 'receipted_message_id',
tagValue: smsId,
},
},
});
if (sent.err) return { err: sent.err };
pduObjs.push(sent.pduObj);
}
return { pduObjs };
}
+42
View File
@@ -0,0 +1,42 @@
export type ConcatInfo = {
part: number;
reference: number;
total: number;
};
/**
* Finds the concatenation information element in a User Data Header, walking the elements rather
* than assuming the header holds nothing else — real SMSCs put ports, language indicators and more
* alongside it.
*
* `message` is a short_message whose first octet is the UDH length.
*/
export function concatInfo(message: Buffer): ConcatInfo | undefined {
const udhLength = message[0];
if (udhLength === undefined) return undefined;
const end = Math.min(udhLength + 1, message.length);
let offset = 1;
while (offset + 2 <= end) {
const iei = message.readUInt8(offset);
const ieLength = message.readUInt8(offset + 1);
const data = message.subarray(offset + 2, offset + 2 + ieLength);
if (offset + 2 + ieLength > end) return undefined;
// 0x00 is an 8-bit concatenation reference, 0x08 a 16-bit one.
if (iei === 0x00 && ieLength === 3) {
return { part: data.readUInt8(2), reference: data.readUInt8(0), total: data.readUInt8(1) };
}
if (iei === 0x08 && ieLength === 4) {
return { part: data.readUInt8(3), reference: data.readUInt16BE(0), total: data.readUInt8(2) };
}
offset += 2 + ieLength;
}
return undefined;
}
+23
View File
@@ -0,0 +1,23 @@
import { randomBytes } from 'node:crypto';
/**
* A UUID version 7: 48 bits of Unix milliseconds followed by random bits, so ids sort by creation
* time without carrying a MAC address the way version 1 does.
*/
export function uuidv7(): string {
const bytes = randomBytes(16);
bytes.writeUIntBE(Date.now(), 0, 6);
bytes.writeUInt8((bytes.readUInt8(6) & 0x0F) | 0x70, 6);
bytes.writeUInt8((bytes.readUInt8(8) & 0x3F) | 0x80, 8);
const hex = bytes.toString('hex');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
}
+417
View File
@@ -0,0 +1,417 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test, { describe } from 'node:test';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
import { client } from '../src/client.ts';
import { server } from '../src/server.ts';
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
const { err, server: smpp } = await server({ ...options, port: 0 });
assert.equal(err, undefined);
assert.ok(smpp);
return smpp;
}
async function connect(smpp: SmppServer, options: Parameters<typeof client>[0] = {}) {
return client({ port: smpp.port, ...options });
}
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>(resolve => { register(resolve); });
}
describe('bind', () => {
test('binds and unbinds against a server with no auth', async () => {
const smpp = await startServer();
const { err, session } = await connect(smpp);
assert.equal(err, undefined);
assert.ok(session);
assert.ok(session.loggedIn);
assert.deepEqual(await session.unbind(), {});
await smpp.close();
});
test('reports the resolved port when 0 was requested', async () => {
const smpp = await startServer();
assert.ok(smpp.port > 0);
await smpp.close();
});
test('refuses the wrong credentials', async () => {
const smpp = await startServer({
authenticate: ({ password, systemId }) => systemId === 'foo' && password === 'bar',
});
const { err, session } = await connect(smpp);
assert.ok(err instanceof Error);
assert.equal(session, undefined);
await smpp.close();
});
test('accepts the right credentials and attaches userData', async () => {
const smpp = await startServer({
authenticate: ({ password, systemId }) => systemId === 'foo' && password === 'bar'
? { userData: { userId: 123 } }
: false,
});
const serverSession = once<Session>(resolve => smpp.on('session', resolve));
const { err, session } = await connect(smpp, { password: 'bar', username: 'foo' });
assert.equal(err, undefined);
assert.ok(session);
const bound = await serverSession;
assert.deepEqual(bound.userData, { userId: 123 });
session.close();
await smpp.close();
});
test('answers a non-bind command from an unbound peer with ESME_RINVBNDSTS', async () => {
const smpp = await startServer();
const responded = once<string>(resolve => {
const sock = net.connect({ port: smpp.port }, () => {
// enquire_link before binding
sock.write(Buffer.from('00000010000000150000000000000001', 'hex'));
});
sock.on('data', data => {
sock.destroy();
resolve(data.toString('hex'));
});
});
// 0x00000004 is ESME_RINVBNDSTS
assert.equal(await responded, '00000010800000150000000400000001');
await smpp.close();
});
});
describe('sending', () => {
test('delivers a simple SMS with the sender TON derived from the address', 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 [sms, sent] = await Promise.all([
incoming.then(async received => {
received.smsId = 'fixed-id';
await received.sendResp();
return received;
}),
session.sendSms({ from: 'MyBrand', message: 'hello world', to: '46709771337' }),
]);
assert.equal(sms.from, 'MyBrand');
assert.equal(sms.to, '46709771337');
assert.equal(sms.message, 'hello world');
assert.equal(sms.dlr, false);
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, ['fixed-id']);
// 0.4.0 sent TON 1 for every sender, including alphanumeric ones, which require TON 5.
const submitted = sms.pduObjs[0];
assert.ok(submitted);
assert.equal(submitted.params.source_addr_ton, 5);
assert.equal(submitted.params.dest_addr_ton, 1);
session.close();
await smpp.close();
});
test('reassembles a long SMS and answers every segment', async () => {
const smpp = await startServer();
const message = 'Lorem ipsum dolor sit amet, '.repeat(20);
const incoming = once<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const { session } = await connect(smpp);
assert.ok(session);
const [sms, sent] = await Promise.all([
incoming.then(async received => {
received.smsId = 'long-id';
await received.sendResp();
return received;
}),
session.sendSms({ from: '46701113311', message, to: '46709771337' }),
]);
assert.equal(sms.message, message);
assert.ok(sms.pduObjs.length > 1);
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, ['long-id-1', 'long-id-2', 'long-id-3', 'long-id-4']);
session.close();
await smpp.close();
});
test('carries a UCS2 message through unchanged', async () => {
const smpp = await startServer();
const message = 'räksmörgås تست 一';
const incoming = once<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const { session } = await connect(smpp);
assert.ok(session);
const [sms] = await Promise.all([
incoming.then(async received => {
await received.sendResp();
return received;
}),
session.sendSms({ from: '46701113311', message, to: '46709771337' }),
]);
assert.equal(sms.message, message);
session.close();
await smpp.close();
});
test('marks a flash message without losing the UCS2 alphabet', 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 [sms] = await Promise.all([
incoming.then(async received => {
await received.sendResp();
return received;
}),
session.sendSms({ flash: true, from: '46701113311', message: 'تست', to: '46709771337' }),
]);
// 0.4.0 forced data_coding to 0x10, which discards UCS2 and mangles the message.
assert.equal(sms.pduObjs[0]?.params.data_coding, 0x18);
assert.equal(sms.message, 'تست');
assert.ok(sms.flash);
session.close();
await smpp.close();
});
});
describe('delivery reports', () => {
test('reaches the sender as a dlr event', 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 dlr = once<{ smsId: string; statusMsg: string }>(resolve => {
session.on('dlr', resolve);
});
const [sms] = await Promise.all([
incoming.then(async received => {
received.smsId = 'dlr-id';
await received.sendResp();
return received;
}),
session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }),
]);
assert.ok(sms.dlr);
await sms.sendDlr();
const report = await dlr;
assert.equal(report.smsId, 'dlr-id');
assert.equal(report.statusMsg, 'DELIVERED');
session.close();
await smpp.close();
});
test('reports a failure with the spec status code', 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 dlr = once<{ statusMsg: string }>(resolve => { session.on('dlr', resolve); });
const raw = once<string>(resolve => {
session.on('incomingPduObj', pduObj => {
const message = pduObj.params.short_message;
if (typeof message === 'string') resolve(message);
});
});
const [sms] = await Promise.all([
incoming.then(async received => {
received.smsId = 'fail-id';
await received.sendResp();
return received;
}),
session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }),
]);
await sms.sendDlr('UNDELIVERABLE');
assert.equal((await dlr).statusMsg, 'UNDELIVERABLE');
// 0.4.0 wrote stat:UNDELIVERABLE, which is not the spec's seven-character field.
assert.match(await raw, /stat:UNDELIV /);
session.close();
await smpp.close();
});
});
describe('a session captured from Kannel', () => {
// Four parts of one message, esm_class 0x43 — the UDH indicator combined with store-and-forward,
// which 0.4.0 originally compared with === 0x40 and missed.
const parts = [
'000000de00000004000000000000003000050074657374000201313233343500430000003136303630373136333031333030302b00000000009f0500030204014c6f72656d20497073756d2069732073696d706c792064756d6d792074657874206f6620746865207072696e74696e6720616e64207479706573657474696e6720696e6475737472792e204c6f72656d20497073756d20686173206265656e2074686520696e6475737472792773207374616e646172642064756d6d79207465787420657665722073696e6365207468652031353030732c200426000101',
'000000de00000004000000000000003100050074657374000201313233343500430000003136303630373136333031333030302b00000000009f0500030204027768656e20616e20756e6b6e6f776e207072696e74657220746f6f6b20612067616c6c6579206f66207479706520616e6420736372616d626c656420697420746f206d616b65206120747970652073706563696d656e20626f6f6b2e20497420686173207375727669766564206e6f74206f6e6c7920666976652063656e7475726965732c2062757420616c736f20746865206c65617020690426000101',
'000000de00000004000000000000003200050074657374000201313233343500430000003136303630373136333031333030302b00000000009f0500030204036e746f20656c656374726f6e6963207479706573657474696e672c2072656d61696e696e6720657373656e7469616c6c7920756e6368616e6765642e2049742077617320706f70756c61726973656420696e207468652031393630732077697468207468652072656c65617365206f66204c657472617365742073686565747320636f6e7461696e696e67204c6f72656d20497073756d20700426000101',
'000000b200000004000000000000003300050074657374000201313233343500430000003136303630373136333031333030302b000000000078050003020404617373616765732c20616e64206d6f726520726563656e746c792077697468206465736b746f70207075626c697368696e6720736f667477617265206c696b6520416c64757320506167654d616b657220696e636c7564696e672076657273696f6e73206f66204c6f72656d20497073756d',
];
const expected = 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum';
async function replay(order: number[]): Promise<Sms> {
const smpp = await startServer();
const incoming = once<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const sock = net.connect({ port: smpp.port }, () => {
sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex'));
});
let bound = false;
sock.on('data', () => {
if (bound) return;
bound = true;
for (const index of order) {
const part = parts[index];
if (part !== undefined) sock.write(Buffer.from(part, 'hex'));
}
});
const sms = await incoming;
await sms.sendResp();
sock.destroy();
await smpp.close();
return sms;
}
test('reassembles four segments arriving in order', async () => {
assert.equal((await replay([0, 1, 2, 3])).message, expected);
});
test('reassembles the same four segments arriving out of order', async () => {
assert.equal((await replay([1, 0, 3, 2])).message, expected);
});
});
describe('robustness', () => {
// 0.4.0 registered a listener per sequence number and waited forever, leaking one per call.
test('gives up on a peer that never answers', async () => {
const accepted: net.Socket[] = [];
// resume() so the socket drains; an unread socket never notices the peer hanging up.
const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); });
await new Promise<void>(resolve => silent.listen(0, resolve));
const address = silent.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
const started = Date.now();
const { err } = await client({ port, responseTimeout: 150 });
assert.ok(err instanceof Error);
assert.ok(Date.now() - started < 5000, 'should have given up quickly');
for (const sock of accepted) sock.destroy();
await new Promise<void>(resolve => silent.close(() => { resolve(); }));
});
test('stops a connection attempt on an aborted signal', async () => {
const controller = new AbortController();
controller.abort();
const { err } = await client({ port: 1, signal: controller.signal });
assert.ok(err instanceof Error);
});
test('reports a refused connection rather than throwing', async () => {
const { err, session } = await client({ port: 1 });
assert.ok(err instanceof Error);
assert.equal(session, undefined);
});
test('keeps at most maxOutstanding requests on the wire', async () => {
const smpp = await startServer();
let concurrent = 0;
let peak = 0;
smpp.on('session', session => {
session.on('sms', sms => {
concurrent++;
peak = Math.max(peak, concurrent);
setTimeout(() => {
concurrent--;
void sms.sendResp();
}, 10);
});
});
const { session } = await connect(smpp, { maxOutstanding: 2 });
assert.ok(session);
await Promise.all(Array.from({ length: 8 }, (_, index) => session.sendSms({
from: '46701113311',
message: `message ${String(index)}`,
to: '46709771337',
})));
assert.ok(peak <= 2, `peak was ${String(peak)}`);
session.close();
await smpp.close();
});
});