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;
};
/** 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 {
if (offset < 0 || needed < 0 || offset + needed > buffer.length) {
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('-');
}