460 lines
12 KiB
TypeScript
460 lines
12 KiB
TypeScript
import type { Dlr } from './dlr.ts';
|
|
import type { ErrorName } from './defs/errors.ts';
|
|
import type { LogInt } from '@larvit/log';
|
|
import type { MessageDlr } from './dlr-merger.ts';
|
|
import type { ParamValue } from './defs/types.ts';
|
|
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
|
import type { Result, VoidResult } from './result.ts';
|
|
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
|
import type { Sms } from './sms.ts';
|
|
import type { Socket } from 'node:net';
|
|
import { DlrMerger } from './dlr-merger.ts';
|
|
import { EventEmitter } from 'node:events';
|
|
import { LinkTimers } from './link-timers.ts';
|
|
import { PduFramer } from './pdu-framer.ts';
|
|
import { PendingRequests } from './pending-requests.ts';
|
|
import { ReconnectLoop } from './reconnect-loop.ts';
|
|
import { Reassembler, decodeSegments } from './reassembly.ts';
|
|
import { SendWindow } from './send-window.ts';
|
|
import { concatInfo } from './udh.ts';
|
|
import { consts, optionalParamsMinVersion } from './defs/constants.ts';
|
|
import { createSms } from './sms.ts';
|
|
import { dlrFromPdu } from './dlr.ts';
|
|
import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
|
|
import { paramText } from './defs/types.ts';
|
|
import { silentLog } from './log.ts';
|
|
import { submitSms } from './send-sms.ts';
|
|
|
|
export type { MessageDlr, SendSmsOptions };
|
|
|
|
export type 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 SessionOptions = {
|
|
enquireLinkInterval?: number | undefined;
|
|
idleTimeout?: number | undefined;
|
|
log?: LogInt | undefined;
|
|
maxOutstanding?: number | undefined;
|
|
maxReassembly?: number | undefined;
|
|
/**
|
|
* First refusal on every incoming request. Returning true means the hook answered it and the
|
|
* built-in handling is skipped — this is how the server owns bind without the session also
|
|
* replying "invalid command".
|
|
*/
|
|
onRequest?: ((session: Session, pduObj: PduObject) => Promise<boolean>) | undefined;
|
|
reassemblyTimeout?: number | undefined;
|
|
reconnect?: ReconnectOptions | undefined;
|
|
responseTimeout?: number | undefined;
|
|
sock: Socket;
|
|
/** This end's own identity, answered to the peer in place of the one it sent. */
|
|
systemId?: string | undefined;
|
|
};
|
|
|
|
const defaults = {
|
|
maxDelay: 30_000,
|
|
maxOutstanding: 10,
|
|
maxReassembly: 1000,
|
|
minDelay: 1000,
|
|
reassemblyTimeout: 300_000,
|
|
responseTimeout: 30_000,
|
|
systemId: '',
|
|
};
|
|
|
|
export const bindCommands: readonly string[] = [
|
|
'bind_receiver',
|
|
'bind_transceiver',
|
|
'bind_transmitter',
|
|
];
|
|
|
|
export class Session extends EventEmitter<SessionEvents> {
|
|
/** Replaced on reconnect, so hold the session rather than this. */
|
|
sock: Socket;
|
|
readonly log: LogInt;
|
|
|
|
loggedIn = false;
|
|
/** The interface_version the peer declared when binding; undefined until a bind is accepted. */
|
|
peerInterfaceVersion: number | undefined = undefined;
|
|
userData: unknown = undefined;
|
|
|
|
private readonly dlrMerger = new DlrMerger();
|
|
private readonly options: SessionOptions;
|
|
private readonly pending: PendingRequests;
|
|
private readonly reassembler: Reassembler;
|
|
private readonly reconnectLoop: ReconnectLoop | undefined;
|
|
private readonly timers: LinkTimers;
|
|
private readonly window: SendWindow;
|
|
|
|
private closed = false;
|
|
private concatReference = 0;
|
|
private framer = new PduFramer();
|
|
|
|
constructor(options: SessionOptions) {
|
|
super();
|
|
|
|
this.log = options.log ?? silentLog;
|
|
this.options = options;
|
|
this.pending = new PendingRequests(this.log);
|
|
this.reassembler = new Reassembler({
|
|
log: this.log,
|
|
max: options.maxReassembly ?? defaults.maxReassembly,
|
|
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
|
|
});
|
|
this.reconnectLoop = this.loopFor(options.reconnect);
|
|
this.sock = options.sock;
|
|
this.timers = new LinkTimers({
|
|
enquireLinkInterval: options.enquireLinkInterval,
|
|
idleTimeout: options.idleTimeout,
|
|
log: this.log,
|
|
onEnquireLink: () => { void this.send({ cmdName: 'enquire_link' }); },
|
|
onIdle: () => { this.close(); },
|
|
});
|
|
this.window = new SendWindow(options.maxOutstanding ?? defaults.maxOutstanding);
|
|
|
|
this.attach(options.sock);
|
|
this.resetTimers();
|
|
}
|
|
|
|
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
|
|
acceptsOptionalParams(): boolean {
|
|
return this.peerInterfaceVersion === undefined
|
|
|| this.peerInterfaceVersion >= optionalParamsMinVersion;
|
|
}
|
|
|
|
/** 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.window.acquire();
|
|
|
|
try {
|
|
return await this.request(input, options);
|
|
} finally {
|
|
this.window.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<SendSmsResult> {
|
|
const sent = await submitSms({
|
|
log: this.log,
|
|
reference: this.nextConcatReference(),
|
|
send: input => this.send(input, options),
|
|
}, sms);
|
|
|
|
if (!sent.err) this.dlrMerger.expect(sent.smsIds);
|
|
|
|
return sent;
|
|
}
|
|
|
|
/** 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.reconnectLoop?.stop();
|
|
this.teardown();
|
|
}
|
|
|
|
private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined {
|
|
if (!reconnect) return undefined;
|
|
|
|
return new ReconnectLoop({
|
|
connect: reconnect.connect,
|
|
log: this.log,
|
|
maxDelay: reconnect.maxDelay ?? defaults.maxDelay,
|
|
minDelay: reconnect.minDelay ?? defaults.minDelay,
|
|
onConnected: sock => this.comeBackUp(sock, reconnect.onConnected),
|
|
});
|
|
}
|
|
|
|
private async comeBackUp(
|
|
sock: Socket,
|
|
bind: (session: Session) => Promise<VoidResult>,
|
|
): Promise<VoidResult> {
|
|
this.attach(sock);
|
|
|
|
const bound = await bind(this);
|
|
|
|
if (bound.err) {
|
|
this.teardown();
|
|
|
|
return { err: bound.err };
|
|
}
|
|
|
|
this.resetTimers();
|
|
this.log.info('session - reconnected');
|
|
this.emit('reconnected');
|
|
|
|
return {};
|
|
}
|
|
|
|
/** 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();
|
|
});
|
|
}
|
|
|
|
private async request(
|
|
input: PduObjectInput,
|
|
options: SendOptions,
|
|
): Promise<Result<{ pduObj: PduObject }>> {
|
|
const seqNr = this.pending.nextSeqNr();
|
|
const built = objToPdu({ ...input, seqNr });
|
|
|
|
if (built.err) return { err: built.err };
|
|
|
|
const response = this.pending.wait(seqNr, {
|
|
signal: options.signal,
|
|
timeout: this.options.responseTimeout ?? defaults.responseTimeout,
|
|
});
|
|
const written = this.write(built.buffer);
|
|
|
|
if (written.err) {
|
|
this.pending.settle(seqNr, { err: written.err });
|
|
|
|
return { err: written.err };
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private teardown(): void {
|
|
if (this.closed) return;
|
|
|
|
this.closed = true;
|
|
this.timers.clear();
|
|
this.pending.settleAll(new Error('Session closed before a response arrived'));
|
|
this.reassembler.clear();
|
|
this.sock.destroy();
|
|
this.emit('close');
|
|
}
|
|
|
|
private nextConcatReference(): number {
|
|
this.concatReference = this.concatReference >= 255 ? 1 : this.concatReference + 1;
|
|
|
|
return this.concatReference;
|
|
}
|
|
|
|
private write(pdu: Buffer): VoidResult {
|
|
if (this.sock.destroyed) {
|
|
return { err: new Error('Socket is closed') };
|
|
}
|
|
|
|
this.sock.write(pdu);
|
|
|
|
return {};
|
|
}
|
|
|
|
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) {
|
|
if (!this.receive(pdu)) return;
|
|
}
|
|
}
|
|
|
|
/** False means the PDU could not be read and the session has been closed. */
|
|
private receive(pdu: Buffer): boolean {
|
|
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 false;
|
|
}
|
|
|
|
this.dispatch(parsed.pduObj);
|
|
|
|
return true;
|
|
}
|
|
|
|
private dispatch(pduObj: PduObject): void {
|
|
if (isResp(pduObj)) {
|
|
if (!this.pending.deliver(pduObj)) {
|
|
this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr });
|
|
}
|
|
|
|
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;
|
|
|
|
switch (pduObj.cmdName) {
|
|
case 'deliver_sm':
|
|
await this.onDeliverSm(pduObj);
|
|
break;
|
|
case 'enquire_link':
|
|
await this.sendReturn(pduObj);
|
|
break;
|
|
case 'submit_sm':
|
|
this.onSubmitSm(pduObj);
|
|
break;
|
|
case 'unbind':
|
|
await this.sendReturn(pduObj);
|
|
this.close();
|
|
break;
|
|
default:
|
|
await this.unhandled(pduObj);
|
|
}
|
|
}
|
|
|
|
private async unhandled(pduObj: PduObject): Promise<void> {
|
|
if (bindCommands.includes(pduObj.cmdName)) {
|
|
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
|
|
// Explicit, or pduReturn's echo answers the peer with its own system_id instead of ours.
|
|
await this.sendReturn(pduObj, 'ESME_RALYBND', {
|
|
system_id: this.options.systemId ?? defaults.systemId,
|
|
});
|
|
|
|
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;
|
|
const concat = hasUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
|
|
|
|
if (!concat) {
|
|
this.emitSms([pduObj]);
|
|
|
|
return;
|
|
}
|
|
|
|
const whole = this.reassembler.collect(pduObj, concat);
|
|
|
|
if (whole) this.emitSms(whole);
|
|
}
|
|
|
|
private emitSms(pduObjs: PduObject[]): void {
|
|
const first = pduObjs[0];
|
|
|
|
if (!first) return;
|
|
|
|
this.emit('sms', createSms({
|
|
from: paramText(first.params.source_addr),
|
|
message: decodeSegments(pduObjs),
|
|
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);
|
|
|
|
const merged = this.dlrMerger.collect(dlr);
|
|
|
|
if (merged) this.emit('messageDlr', merged);
|
|
|
|
await this.sendReturn(pduObj);
|
|
}
|
|
|
|
private resetTimers(): void {
|
|
if (this.closed) return;
|
|
|
|
this.timers.reset();
|
|
}
|
|
|
|
private onClose(): void {
|
|
this.teardown();
|
|
this.reconnectLoop?.schedule();
|
|
}
|
|
}
|