import type { ConnectionOptions } from 'node:tls'; import type { Result, VoidResult } from './result.ts'; import type { BindType, ReconnectOptions } from './session-options.ts'; import type { SmppLog } from './log.ts'; import type { SmsIdFormat } from './sms-id.ts'; import type { Socket } from 'node:net'; export type { BindType }; import { Session } from './session.ts'; import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; import { connect as netConnect } from 'node:net'; import { connect as tlsConnect } from 'node:tls'; import { defaultInterfaceVersion } from './defs/constants.ts'; import { silentLog } from './log.ts'; export type ClientOptions = { addressRange?: string; addrNpi?: number; addrTon?: number; bindType?: BindType; enquireLinkInterval?: number; host?: string; idleTimeout?: number; interfaceVersion?: number; log?: SmppLog; maxOutstanding?: number; password?: string; port?: number; reconnect?: { maxDelay?: number; minDelay?: number } | false; responseTimeout?: number; shutdownTimeout?: number; signal?: AbortSignal; smsIdFormat?: SmsIdFormat; systemType?: string; tls?: ConnectionOptions | boolean; username?: string; }; 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; function openSocket(options: ClientOptions): Promise> { const host = options.host ?? defaults.host; const port = options.port ?? defaults.port; const secure = options.tls !== undefined && options.tls !== false; const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; return new Promise(resolve => { const signal = options.signal; if (signal?.aborted === true) { resolve({ err: new Error('Aborted before connecting') }); return; } 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); 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(secure ? 'secureConnect' : 'connect', () => { settle({ sock }); }); }); } function bindParams(options: ClientOptions, systemId: string) { return { 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 ?? '', }; } async function bind(session: Session, options: ClientOptions): Promise { const bindType = options.bindType ?? defaults.bindType; const systemId = options.username ?? defaults.username; const sent = await session.send( { cmdName: `bind_${bindType}`, params: bindParams(options, systemId) }, 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'}`) }; } const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue; session.boundAs = bindType; session.loggedIn = true; session.peerInterfaceVersion = typeof declared === 'number' ? declared : undeclaredInterfaceVersion; session.log.info('client - bound', { bindType, systemId }); return {}; } function reconnectFor(options: ClientOptions): ReconnectOptions | undefined { if (options.reconnect === false) return undefined; const tuning = options.reconnect ?? {}; return { connect: () => openSocket(options), maxDelay: tuning.maxDelay, minDelay: tuning.minDelay, onConnected: reconnected => bind(reconnected, options), }; } function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Session { const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval; return new Session({ enquireLinkInterval, idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, log, maxOutstanding: options.maxOutstanding, reconnect: reconnectFor(options), responseTimeout: options.responseTimeout, shutdownTimeout: options.shutdownTimeout, smsIdFormat: options.smsIdFormat, sock, }); } async function connect(options: ClientOptions, log: SmppLog): Promise> { const checked = checkSessionOptions(options); if (checked.err) { log.warn('client - unusable option', { message: checked.err.message }); return { err: checked.err }; } 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 }; } return { sock: opened.sock }; } /** Connects to an SMSC and binds. */ export async function client(options: ClientOptions = {}): Promise> { const log = options.log ?? silentLog; const opened = await connect(options, log); if (opened.err) return { err: opened.err }; const session = createSession(options, log, opened.sock); const signal = options.signal; if (signal?.aborted === true) { void session.close({ signal }); 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', () => { void session.close({ signal }); }, { once: true }); const bound = await bind(session, options); if (bound.err) { void session.close({ signal }); return { err: bound.err }; } return { session }; }