Close the stability findings and settle the sendResp, sendSms and unbind contracts
This commit is contained in:
+46
-26
@@ -17,6 +17,7 @@ export type ClientOptions = {
|
||||
bindType?: BindType;
|
||||
enquireLinkInterval?: number;
|
||||
host?: string;
|
||||
idleTimeout?: number;
|
||||
interfaceVersion?: number;
|
||||
log?: LogInt;
|
||||
maxOutstanding?: number;
|
||||
@@ -34,16 +35,14 @@ const defaults = {
|
||||
bindType: 'transceiver',
|
||||
enquireLinkInterval: 20_000,
|
||||
host: 'localhost',
|
||||
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */
|
||||
idleTimeoutFactor: 2,
|
||||
interfaceVersion: defaultInterfaceVersion,
|
||||
password: 'pass',
|
||||
port: 2775,
|
||||
username: 'user',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Opens the socket. 0.4.0 built a bare `new tls.Socket()` for `tls: true`, which never performs a
|
||||
* handshake, so those connections were not encrypted at all.
|
||||
*/
|
||||
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
||||
const host = options.host ?? defaults.host;
|
||||
const port = options.port ?? defaults.port;
|
||||
@@ -59,7 +58,15 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
||||
return;
|
||||
}
|
||||
|
||||
const sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port });
|
||||
let sock: Socket;
|
||||
|
||||
try {
|
||||
sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port });
|
||||
} catch (thrown: unknown) {
|
||||
resolve({ err: thrown instanceof Error ? thrown : new Error(String(thrown)) });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const settle = (result: Result<{ sock: Socket }>): void => {
|
||||
sock.removeListener('error', onError);
|
||||
@@ -122,6 +129,29 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
|
||||
return {};
|
||||
}
|
||||
|
||||
function createSession(options: ClientOptions, log: LogInt, sock: Socket): Session {
|
||||
const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval;
|
||||
|
||||
return new Session({
|
||||
enquireLinkInterval,
|
||||
idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor,
|
||||
log,
|
||||
maxOutstanding: options.maxOutstanding,
|
||||
responseTimeout: options.responseTimeout,
|
||||
sock,
|
||||
...(options.reconnect
|
||||
? {
|
||||
reconnect: {
|
||||
connect: () => openSocket(options),
|
||||
maxDelay: options.reconnect.maxDelay,
|
||||
minDelay: options.reconnect.minDelay,
|
||||
onConnected: reconnected => bind(reconnected, options),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Connects to an SMSC and binds. */
|
||||
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
|
||||
const log = options.log ?? silentLog;
|
||||
@@ -137,23 +167,17 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
|
||||
return { err: opened.err };
|
||||
}
|
||||
|
||||
const session = new Session({
|
||||
enquireLinkInterval: options.enquireLinkInterval ?? defaults.enquireLinkInterval,
|
||||
log,
|
||||
maxOutstanding: options.maxOutstanding,
|
||||
responseTimeout: options.responseTimeout,
|
||||
sock: opened.sock,
|
||||
...(options.reconnect
|
||||
? {
|
||||
reconnect: {
|
||||
connect: () => openSocket(options),
|
||||
maxDelay: options.reconnect.maxDelay,
|
||||
minDelay: options.reconnect.minDelay,
|
||||
onConnected: reconnected => bind(reconnected, options),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const session = createSession(options, log, opened.sock);
|
||||
const signal = options.signal;
|
||||
|
||||
if (signal?.aborted === true) {
|
||||
session.close();
|
||||
|
||||
return { err: new Error('Aborted before binding') };
|
||||
}
|
||||
|
||||
// Registered before the bind: an abort landing while it is in flight has to close the session.
|
||||
signal?.addEventListener('abort', () => { session.close(); }, { once: true });
|
||||
|
||||
const bound = await bind(session, options);
|
||||
|
||||
@@ -163,9 +187,5 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
|
||||
return { err: bound.err };
|
||||
}
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener('abort', () => { session.close(); }, { once: true });
|
||||
}
|
||||
|
||||
return { session };
|
||||
}
|
||||
|
||||
+48
-7
@@ -55,6 +55,26 @@ function wantInt(value: ParamValue, max: number): Result<{ int: number }> {
|
||||
return { int: value };
|
||||
}
|
||||
|
||||
function writeInt8(value: ParamValue, buf: Buffer, offset: number): VoidResult {
|
||||
const { err, int } = wantInt(value, 0xFF);
|
||||
|
||||
if (err) return { err };
|
||||
|
||||
buf.writeUInt8(int, offset);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function writeInt32(value: ParamValue, buf: Buffer, offset: number): VoidResult {
|
||||
const { err, int } = wantInt(value, 0xFFFFFFFF);
|
||||
|
||||
if (err) return { err };
|
||||
|
||||
buf.writeUInt32BE(int, offset);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function wantText(value: ParamValue): Result<{ text: string }> {
|
||||
if (typeof value === 'string') return { text: value };
|
||||
if (typeof value === 'number') return { text: value.toString() };
|
||||
@@ -323,7 +343,9 @@ export const dest_address_array: WireType<DestAddress[]> = {
|
||||
|
||||
if (rangeErr) return { err: rangeErr };
|
||||
|
||||
buf.writeUInt8(addresses.length, offset++);
|
||||
const count = writeInt8(addresses.length, buf, offset++);
|
||||
|
||||
if (count.err) return { err: count.err };
|
||||
|
||||
for (const dest of addresses) {
|
||||
if ('dl_name' in dest) {
|
||||
@@ -332,8 +354,15 @@ export const dest_address_array: WireType<DestAddress[]> = {
|
||||
offset += dest.dl_name.length + 1;
|
||||
} else {
|
||||
buf.writeUInt8(1, offset++);
|
||||
buf.writeUInt8(dest.dest_addr_ton, offset++);
|
||||
buf.writeUInt8(dest.dest_addr_npi, offset++);
|
||||
|
||||
const ton = writeInt8(dest.dest_addr_ton, buf, offset++);
|
||||
|
||||
if (ton.err) return { err: ton.err };
|
||||
|
||||
const npi = writeInt8(dest.dest_addr_npi, buf, offset++);
|
||||
|
||||
if (npi.err) return { err: npi.err };
|
||||
|
||||
writeCstring(dest.destination_addr, buf, offset);
|
||||
offset += dest.destination_addr.length + 1;
|
||||
}
|
||||
@@ -406,14 +435,26 @@ export const unsuccess_sme_array: WireType<UnsuccessSme[]> = {
|
||||
|
||||
if (rangeErr) return { err: rangeErr };
|
||||
|
||||
buf.writeUInt8(smes.length, offset++);
|
||||
const count = writeInt8(smes.length, buf, offset++);
|
||||
|
||||
if (count.err) return { err: count.err };
|
||||
|
||||
for (const sme of smes) {
|
||||
buf.writeUInt8(sme.dest_addr_ton, offset++);
|
||||
buf.writeUInt8(sme.dest_addr_npi, offset++);
|
||||
const ton = writeInt8(sme.dest_addr_ton, buf, offset++);
|
||||
|
||||
if (ton.err) return { err: ton.err };
|
||||
|
||||
const npi = writeInt8(sme.dest_addr_npi, buf, offset++);
|
||||
|
||||
if (npi.err) return { err: npi.err };
|
||||
|
||||
writeCstring(sme.destination_addr, buf, offset);
|
||||
offset += sme.destination_addr.length + 1;
|
||||
buf.writeUInt32BE(sme.error_status_code, offset);
|
||||
|
||||
const status = writeInt32(sme.error_status_code, buf, offset);
|
||||
|
||||
if (status.err) return { err: status.err };
|
||||
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
|
||||
+54
-2
@@ -1,7 +1,17 @@
|
||||
import type { Dlr } from './dlr.ts';
|
||||
import type { LogInt } from '@larvit/log';
|
||||
import { ExpiringGroups } from './expiring-groups.ts';
|
||||
|
||||
export type MessageDlr = Dlr & { segments: Dlr[] };
|
||||
|
||||
export type DlrMergerOptions = {
|
||||
log: LogInt;
|
||||
max: number;
|
||||
/** Injected so expiry can be exercised without a wall clock. */
|
||||
now?: (() => number) | undefined;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
type Group = {
|
||||
expected: number;
|
||||
parts: Map<number, Dlr>;
|
||||
@@ -15,7 +25,24 @@ const numbered = /^(.*)-(\d+)$/;
|
||||
* SMSC that hands out unrelated ids per segment cannot be merged, so nothing is reported for it.
|
||||
*/
|
||||
export class DlrMerger {
|
||||
private readonly groups = new Map<string, Group>();
|
||||
private readonly groups: ExpiringGroups<Group>;
|
||||
private readonly log: LogInt;
|
||||
private readonly max: number;
|
||||
|
||||
constructor(options: DlrMergerOptions) {
|
||||
this.groups = new ExpiringGroups<Group>({
|
||||
max: options.max,
|
||||
now: options.now,
|
||||
onSweep: () => { this.sweep(); },
|
||||
timeout: options.timeout,
|
||||
});
|
||||
this.log = options.log;
|
||||
this.max = options.max;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.groups.size;
|
||||
}
|
||||
|
||||
/** Registers the ids one multipart send got back, so their receipts can be merged. */
|
||||
expect(smsIds: string[]): void {
|
||||
@@ -34,12 +61,14 @@ export class DlrMerger {
|
||||
if (bases.size !== 1) return;
|
||||
|
||||
for (const base of bases) {
|
||||
this.groups.set(base, { expected: smsIds.length, parts: new Map() });
|
||||
this.open(base, smsIds.length);
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole message's report, on the receipt that completes it. */
|
||||
collect(dlr: Dlr): MessageDlr | undefined {
|
||||
this.sweep();
|
||||
|
||||
const match = numbered.exec(dlr.smsId);
|
||||
const base = match?.[1];
|
||||
const part = match?.[2];
|
||||
@@ -61,4 +90,27 @@ export class DlrMerger {
|
||||
|
||||
return { ...worst, segments, smsId: base };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.groups.clear();
|
||||
}
|
||||
|
||||
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
|
||||
sweep(): void {
|
||||
for (const [base, group] of this.groups.takeExpired()) {
|
||||
this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected });
|
||||
}
|
||||
}
|
||||
|
||||
private open(base: string, expected: number): void {
|
||||
if (this.groups.full) this.dropOldest();
|
||||
|
||||
this.groups.set(base, { expected, parts: new Map() });
|
||||
}
|
||||
|
||||
private dropOldest(): void {
|
||||
if (!this.groups.takeOldest()) return;
|
||||
|
||||
this.log.warn('dlrMerger - buffer full, dropping the oldest message', { max: this.max });
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ export function parseReceipt(message: string): Receipt {
|
||||
/**
|
||||
* Builds a delivery report from a deliver_sm. The message_state and receipted_message_id TLVs are
|
||||
* authoritative when present; otherwise the receipt text is parsed, which is the only thing Kannel
|
||||
* and several other SMSCs send. 0.4.0 required the TLVs and rejected everything else.
|
||||
* and several other SMSCs send.
|
||||
*/
|
||||
export function dlrFromPdu(pduObj: PduObject): Dlr | undefined {
|
||||
const message = pduObj.params.short_message;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
export type ExpiringGroupsOptions = {
|
||||
max: number;
|
||||
/** Injected so expiry can be exercised without a wall clock. */
|
||||
now?: (() => number) | undefined;
|
||||
/** Runs on the sweeper's own timer; the owner reports whatever it takes out. */
|
||||
onSweep: () => void;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
type Entry<T> = {
|
||||
deadline: number;
|
||||
group: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* A capped store of groups that expire. Nothing is dropped silently: the owner takes the expired
|
||||
* and the evicted out itself, so the accounting and the log line stay where the group is understood.
|
||||
*/
|
||||
export class ExpiringGroups<T> {
|
||||
private readonly entries = new Map<string, Entry<T>>();
|
||||
private readonly max: number;
|
||||
private readonly now: () => number;
|
||||
private readonly onSweep: () => void;
|
||||
private readonly timeout: number;
|
||||
private sweeper: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(options: ExpiringGroupsOptions) {
|
||||
this.max = options.max;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.onSweep = options.onSweep;
|
||||
this.timeout = options.timeout;
|
||||
}
|
||||
|
||||
get full(): boolean {
|
||||
return this.entries.size >= this.max;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
return this.entries.get(key)?.group;
|
||||
}
|
||||
|
||||
/** Starts the group's deadline, and the sweeper if this is the only group held. */
|
||||
set(key: string, group: T): void {
|
||||
this.entries.set(key, { deadline: this.now() + this.timeout, group });
|
||||
|
||||
if (this.sweeper) return;
|
||||
|
||||
this.sweeper = setInterval(() => { this.onSweep(); }, this.timeout);
|
||||
this.sweeper.unref();
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.entries.delete(key);
|
||||
this.idle();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.idle();
|
||||
}
|
||||
|
||||
/** Removes every group past its deadline and hands them over. */
|
||||
takeExpired(): [string, T][] {
|
||||
const now = this.now();
|
||||
const taken: [string, T][] = [];
|
||||
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.deadline > now) continue;
|
||||
|
||||
taken.push([key, entry.group]);
|
||||
this.entries.delete(key);
|
||||
}
|
||||
|
||||
this.idle();
|
||||
|
||||
return taken;
|
||||
}
|
||||
|
||||
/** Removes the group held longest and hands it over. Undefined means there was none. */
|
||||
takeOldest(): [string, T] | undefined {
|
||||
const oldest = this.entries.entries().next();
|
||||
|
||||
if (oldest.done) return undefined;
|
||||
|
||||
const [key, entry] = oldest.value;
|
||||
|
||||
this.delete(key);
|
||||
|
||||
return [key, entry.group];
|
||||
}
|
||||
|
||||
private idle(): void {
|
||||
if (!this.sweeper || this.entries.size > 0) return;
|
||||
|
||||
clearInterval(this.sweeper);
|
||||
this.sweeper = undefined;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -6,6 +6,9 @@ import { detect, encodingByDataCoding, encodings } from './defs/encodings.ts';
|
||||
/** A single SMS carries 1120 bits, whatever the alphabet. */
|
||||
const singleMessageBits = 1120;
|
||||
|
||||
/** The concatenation UDH numbers the segments of a message in a single octet. */
|
||||
export const maxSegments = 255;
|
||||
|
||||
/** Budget per concatenated segment: septets for GSM, octets for UCS2. */
|
||||
const segmentUnits = { ASCII: 153, UCS2: 67 * 2 } as const;
|
||||
|
||||
@@ -52,7 +55,8 @@ export function bitCount(message: string, encoding?: EncodingName): number {
|
||||
|
||||
/**
|
||||
* Splits a message into concatenation segments, each prefixed with a UDH. A message that fits in a
|
||||
* single SMS is returned as one segment with no UDH.
|
||||
* single SMS is returned as one segment with no UDH, and one needing more than `maxSegments` as no
|
||||
* segments at all — a UDH cannot number them.
|
||||
*
|
||||
* Splitting walks code points, so an escaped GSM character never straddles a segment boundary and a
|
||||
* surrogate pair is never cut in half.
|
||||
@@ -84,6 +88,8 @@ export function splitMessage(message: string, options: SplitOptions): Buffer[] {
|
||||
|
||||
if (current !== '') parts.push(current);
|
||||
|
||||
if (parts.length > maxSegments) return [];
|
||||
|
||||
return parts.map((part, index) => Buffer.concat([
|
||||
Buffer.from([0x05, 0x00, 0x03, options.reference & 0xFF, parts.length, index + 1]),
|
||||
encodings[encoding].encode(part),
|
||||
|
||||
+11
-2
@@ -79,12 +79,18 @@ function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
|
||||
return { tagId };
|
||||
}
|
||||
|
||||
/** Encoding a string short_message also settles data_coding and sm_length. */
|
||||
/** Encoding a string short_message settles data_coding and sm_length; a buffer settles sm_length. */
|
||||
function resolveShortMessage(
|
||||
params: Record<string, ParamValue | undefined>,
|
||||
): Record<string, ParamValue | undefined> {
|
||||
const message = params.short_message;
|
||||
|
||||
if (Buffer.isBuffer(message)) {
|
||||
return params.sm_length === undefined
|
||||
? { ...params, sm_length: message.length }
|
||||
: { ...params };
|
||||
}
|
||||
|
||||
if (typeof message !== 'string') return { ...params };
|
||||
|
||||
const dataCoding = params.data_coding;
|
||||
@@ -355,7 +361,10 @@ export function pduToObj(pdu: Buffer): Result<{ pduObj: PduObject }> {
|
||||
return { err: plain.err };
|
||||
}
|
||||
|
||||
/** Fields the response shares with the request are echoed back unless the caller overrode them. */
|
||||
/**
|
||||
* Fields the response shares with the request are echoed back unless the caller overrode them, so a
|
||||
* response that has to state its own value for one — an SMSC's system_id — must pass it.
|
||||
*/
|
||||
function echoParams(
|
||||
respName: CommandName,
|
||||
pdu: PduObject,
|
||||
|
||||
+82
-37
@@ -2,23 +2,60 @@ import type { ConcatInfo } from './udh.ts';
|
||||
import type { LogInt } from '@larvit/log';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { Tlv } from './defs/tlvs.ts';
|
||||
import { ExpiringGroups } from './expiring-groups.ts';
|
||||
import { decodeMessage } from './message.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
|
||||
export type ReassemblerOptions = {
|
||||
log: LogInt;
|
||||
max: number;
|
||||
maxOctets?: number | undefined;
|
||||
/** Injected so expiry can be exercised without a wall clock. */
|
||||
now?: (() => number) | undefined;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
const defaultMaxOctets = 64 * 1024 * 1024;
|
||||
|
||||
type Group = {
|
||||
deadline: number;
|
||||
octets: number;
|
||||
parts: Map<number, PduObject>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
/** Wire reads hand back views, so retaining one segment would pin the whole PDU it arrived in. */
|
||||
function detach(pduObj: PduObject): PduObject {
|
||||
const params: Record<string, ParamValue> = {};
|
||||
const tlvs: Record<string, Tlv> = {};
|
||||
|
||||
for (const [name, value] of Object.entries(pduObj.params)) {
|
||||
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
|
||||
}
|
||||
|
||||
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
|
||||
tlvs[name] = Buffer.isBuffer(tlv.tagValue)
|
||||
? { ...tlv, tagValue: Buffer.from(tlv.tagValue) }
|
||||
: tlv;
|
||||
}
|
||||
|
||||
return { ...pduObj, params, tlvs };
|
||||
}
|
||||
|
||||
function octetsOf(pduObj: PduObject): number {
|
||||
let octets = 0;
|
||||
|
||||
for (const value of Object.values(pduObj.params)) {
|
||||
if (Buffer.isBuffer(value)) octets += value.length;
|
||||
}
|
||||
|
||||
for (const tlv of Object.values(pduObj.tlvs)) {
|
||||
if (Buffer.isBuffer(tlv.tagValue)) octets += tlv.tagValue.length;
|
||||
}
|
||||
|
||||
return octets;
|
||||
}
|
||||
|
||||
function groupKey(pduObj: PduObject, reference: number): string {
|
||||
return [
|
||||
paramText(pduObj.params.source_addr),
|
||||
@@ -52,18 +89,22 @@ export function decodeSegments(pduObjs: PduObject[]): string {
|
||||
|
||||
/** Holds the segments of incomplete multipart messages until they are whole, capped and expiring. */
|
||||
export class Reassembler {
|
||||
private readonly groups = new Map<string, Group>();
|
||||
private readonly groups: ExpiringGroups<Group>;
|
||||
private readonly log: LogInt;
|
||||
private readonly max: number;
|
||||
private readonly now: () => number;
|
||||
private readonly timeout: number;
|
||||
private sweeper: NodeJS.Timeout | undefined;
|
||||
private readonly maxOctets: number;
|
||||
private octets = 0;
|
||||
|
||||
constructor(options: ReassemblerOptions) {
|
||||
this.groups = new ExpiringGroups<Group>({
|
||||
max: options.max,
|
||||
now: options.now,
|
||||
onSweep: () => { this.sweep(); },
|
||||
timeout: options.timeout,
|
||||
});
|
||||
this.log = options.log;
|
||||
this.max = options.max;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.timeout = options.timeout;
|
||||
this.maxOctets = options.maxOctets ?? defaultMaxOctets;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
@@ -76,64 +117,68 @@ export class Reassembler {
|
||||
|
||||
const key = groupKey(pduObj, concat.reference);
|
||||
const group = this.groups.get(key) ?? this.open(key, concat.total);
|
||||
const replaced = group.parts.get(concat.part);
|
||||
const segment = detach(pduObj);
|
||||
const delta = octetsOf(segment) - (replaced === undefined ? 0 : octetsOf(replaced));
|
||||
|
||||
group.parts.set(concat.part, pduObj);
|
||||
group.parts.set(concat.part, segment);
|
||||
group.octets += delta;
|
||||
this.octets += delta;
|
||||
|
||||
if (group.parts.size < group.total) return undefined;
|
||||
if (group.parts.size < group.total) {
|
||||
this.trim();
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.groups.delete(key);
|
||||
this.idle();
|
||||
this.octets -= group.octets;
|
||||
|
||||
return [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, part]) => part);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.groups.clear();
|
||||
this.idle();
|
||||
this.octets = 0;
|
||||
}
|
||||
|
||||
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
|
||||
sweep(): void {
|
||||
const now = this.now();
|
||||
|
||||
for (const [key, group] of this.groups) {
|
||||
if (group.deadline > now) continue;
|
||||
|
||||
for (const [key, group] of this.groups.takeExpired()) {
|
||||
this.log.info('reassembler - incomplete message expired', { key, total: group.total });
|
||||
this.groups.delete(key);
|
||||
this.octets -= group.octets;
|
||||
}
|
||||
|
||||
this.idle();
|
||||
}
|
||||
|
||||
private open(key: string, total: number): Group {
|
||||
if (this.groups.size >= this.max) this.dropOldest();
|
||||
if (this.groups.full) this.dropOldest();
|
||||
|
||||
const group: Group = { deadline: this.now() + this.timeout, parts: new Map(), total };
|
||||
const group: Group = { octets: 0, parts: new Map(), total };
|
||||
|
||||
this.groups.set(key, group);
|
||||
|
||||
if (!this.sweeper) {
|
||||
this.sweeper = setInterval(() => { this.sweep(); }, this.timeout);
|
||||
this.sweeper.unref();
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private dropOldest(): void {
|
||||
const oldest = this.groups.keys().next();
|
||||
|
||||
if (oldest.done) return;
|
||||
|
||||
this.log.warn('reassembler - buffer full, dropping the oldest message', { max: this.max });
|
||||
this.groups.delete(oldest.value);
|
||||
/** Drops the oldest groups until the retained payload is back under the octet cap. */
|
||||
private trim(): void {
|
||||
while (this.octets > this.maxOctets && this.groups.size > 0) {
|
||||
this.dropOldest();
|
||||
}
|
||||
}
|
||||
|
||||
private idle(): void {
|
||||
if (!this.sweeper || this.groups.size > 0) return;
|
||||
private dropOldest(): void {
|
||||
const oldest = this.groups.takeOldest();
|
||||
|
||||
clearInterval(this.sweeper);
|
||||
this.sweeper = undefined;
|
||||
if (!oldest) return;
|
||||
|
||||
const [, group] = oldest;
|
||||
|
||||
this.log.warn('reassembler - buffer full, dropping the oldest message', {
|
||||
max: this.max,
|
||||
maxOctets: this.maxOctets,
|
||||
octets: this.octets,
|
||||
});
|
||||
this.octets -= group.octets;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-7
@@ -14,6 +14,7 @@ export type ReconnectLoopOptions = {
|
||||
/** Reopens a dropped connection, backing off between attempts until it is told to stop. */
|
||||
export class ReconnectLoop {
|
||||
private readonly options: ReconnectLoopOptions;
|
||||
private attempting = false;
|
||||
private delay: number;
|
||||
private halted = false;
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
@@ -29,7 +30,7 @@ export class ReconnectLoop {
|
||||
}
|
||||
|
||||
schedule(): void {
|
||||
if (this.timer || this.isStopped()) return;
|
||||
if (this.timer || this.attempting || this.isStopped()) return;
|
||||
|
||||
const delay = this.delay;
|
||||
|
||||
@@ -53,7 +54,25 @@ export class ReconnectLoop {
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
if (this.isStopped()) return;
|
||||
this.attempting = true;
|
||||
|
||||
// connect() and onConnected() are the application's, so a throw from either lands here.
|
||||
const retry = await this.attempt().catch((thrown: unknown) => {
|
||||
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
|
||||
|
||||
this.options.log.error('reconnect - an attempt threw', { message: err.message });
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
this.attempting = false;
|
||||
|
||||
if (retry) this.schedule();
|
||||
}
|
||||
|
||||
/** True means the attempt failed and the loop should try again. */
|
||||
private async attempt(): Promise<boolean> {
|
||||
if (this.isStopped()) return false;
|
||||
|
||||
const opened = await this.options.connect();
|
||||
|
||||
@@ -61,26 +80,26 @@ export class ReconnectLoop {
|
||||
this.options.log.warn('reconnect - could not open a socket', {
|
||||
message: opened.err.message,
|
||||
});
|
||||
this.schedule();
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isStopped()) {
|
||||
opened.sock.destroy();
|
||||
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const up = await this.options.onConnected(opened.sock);
|
||||
|
||||
if (up.err) {
|
||||
this.options.log.warn('reconnect - could not come back up', { message: up.err.message });
|
||||
this.schedule();
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
this.delay = this.options.minDelay;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-8
@@ -6,7 +6,7 @@ import type { Result } from './result.ts';
|
||||
import { consts } from './defs/constants.ts';
|
||||
import { detect } from './defs/encodings.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { smppTime, splitMessage } from './message.ts';
|
||||
import { maxSegments, smppTime, splitMessage } from './message.ts';
|
||||
|
||||
export type SendSmsOptions = {
|
||||
dlr?: boolean;
|
||||
@@ -23,7 +23,8 @@ export type SendSmsOptions = {
|
||||
validityPeriod?: Date | number | string;
|
||||
};
|
||||
|
||||
export type SendSmsResult = Result<{ pduObjs: PduObject[]; smsIds: string[] }>;
|
||||
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
|
||||
export type SendSmsResult = { err?: Error; pduObjs: PduObject[]; smsIds: string[] };
|
||||
|
||||
/** What sending needs from the session: a concat reference and a way onto the wire. */
|
||||
export type SendSmsDeps = {
|
||||
@@ -37,7 +38,7 @@ type SegmentOptions = {
|
||||
multipart: boolean;
|
||||
};
|
||||
|
||||
/** Alphanumeric senders must be TON 5; 0.4.0 sent everything as TON 1 (international). */
|
||||
/** Alphanumeric senders must be TON 5. */
|
||||
function addressTon(address: string): number {
|
||||
return /^\+?\d+$/.test(address) ? consts.TON.INTERNATIONAL : consts.TON.ALPHANUMERIC;
|
||||
}
|
||||
@@ -82,6 +83,15 @@ export function submitSmParams(
|
||||
export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise<SendSmsResult> {
|
||||
const encoding = sms.encoding ?? detect(sms.message);
|
||||
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
|
||||
|
||||
if (segments.length === 0) {
|
||||
return {
|
||||
err: new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`),
|
||||
pduObjs: [],
|
||||
smsIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const multipart = segments.length > 1;
|
||||
const pduObjs: PduObject[] = [];
|
||||
const smsIds: string[] = [];
|
||||
@@ -95,12 +105,20 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise
|
||||
params: submitSmParams(sms, segment, { encoding, multipart }),
|
||||
})));
|
||||
|
||||
for (const one of sent) {
|
||||
if (one.err) return { err: one.err };
|
||||
let failure: Error | undefined;
|
||||
|
||||
pduObjs.push(one.pduObj);
|
||||
smsIds.push(paramText(one.pduObj.params.message_id));
|
||||
for (const one of sent) {
|
||||
if (one.err) {
|
||||
failure ??= one.err;
|
||||
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
|
||||
pduObjs.push(one.pduObj);
|
||||
smsIds.push(paramText(one.pduObj.params.message_id));
|
||||
} else {
|
||||
const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId);
|
||||
|
||||
failure ??= new Error(`submit_sm refused by the peer: ${refusal}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { pduObjs, smsIds };
|
||||
return failure ? { err: failure, pduObjs, smsIds } : { pduObjs, smsIds };
|
||||
}
|
||||
|
||||
+26
-9
@@ -128,7 +128,6 @@ async function acceptBind(
|
||||
|
||||
async function onBind(session: Session, pduObj: PduObject, options: ServerOptions): Promise<void> {
|
||||
const log = options.log ?? silentLog;
|
||||
// Explicit, or pduReturn's echo answers the ESME with its own system_id instead of ours.
|
||||
const identity = { system_id: options.systemId ?? defaults.systemId };
|
||||
const systemId = paramText(pduObj.params.system_id);
|
||||
|
||||
@@ -211,6 +210,7 @@ function createListener(
|
||||
): Result<{ listener: NetServer | TlsServer; useTls: boolean }> {
|
||||
const useTls = options.tls !== undefined && options.tls !== false;
|
||||
const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined;
|
||||
const version = options.interfaceVersion ?? defaults.interfaceVersion;
|
||||
|
||||
if (useTls && !tlsOptions) {
|
||||
log.warn('server - tls without a certificate', { port });
|
||||
@@ -218,6 +218,13 @@ function createListener(
|
||||
return { err: new Error('Listening over TLS needs tls: { cert, key }') };
|
||||
}
|
||||
|
||||
// An int8 TLV on every bind response: out of range here means no ESME can ever bind.
|
||||
if (!Number.isInteger(version) || version < 0 || version > 0xFF) {
|
||||
log.warn('server - interface version out of range', { interfaceVersion: version });
|
||||
|
||||
return { err: new Error(`interfaceVersion must be 0-255, got ${String(version)}`) };
|
||||
}
|
||||
|
||||
return {
|
||||
listener: tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(),
|
||||
useTls,
|
||||
@@ -235,9 +242,15 @@ function onListening(listener: NetServer, smpp: SmppServer, options: ServerOptio
|
||||
|
||||
log.info('server - listening', { host: options.host ?? '*', port: smpp.port });
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener('abort', () => { void smpp.close(); }, { once: true });
|
||||
}
|
||||
// close() runs the application's own 'close' listeners, so a throw from one lands here.
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
void smpp.close().catch((thrown: unknown) => {
|
||||
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
|
||||
|
||||
log.warn('server - could not close on abort', { message: err.message });
|
||||
smpp.emit('serverError', err);
|
||||
});
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
/** Starts listening for SMPP connections. Resolves once the socket is bound. */
|
||||
@@ -264,10 +277,14 @@ export function server(options: ServerOptions = {}): Promise<Result<{ server: Sm
|
||||
|
||||
listener.once('error', onStartupError);
|
||||
|
||||
listener.listen(port, options.host, () => {
|
||||
listener.removeListener('error', onStartupError);
|
||||
onListening(listener, smpp, options);
|
||||
resolve({ server: smpp });
|
||||
});
|
||||
try {
|
||||
listener.listen(port, options.host, () => {
|
||||
listener.removeListener('error', onStartupError);
|
||||
onListening(listener, smpp, options);
|
||||
resolve({ server: smpp });
|
||||
});
|
||||
} catch (thrown: unknown) {
|
||||
onStartupError(thrown instanceof Error ? thrown : new Error(String(thrown)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { LogInt } from '@larvit/log';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Session } from './session.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export type SendOptions = { signal?: AbortSignal | undefined };
|
||||
|
||||
/**
|
||||
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller
|
||||
* supplies how to open a socket and what to do once it is open (bind, for a client).
|
||||
*/
|
||||
export type ReconnectOptions = {
|
||||
connect: () => Promise<Result<{ sock: Socket }>>;
|
||||
maxDelay?: number | undefined;
|
||||
minDelay?: number | undefined;
|
||||
onConnected: (session: Session) => Promise<VoidResult>;
|
||||
};
|
||||
|
||||
export type SessionOptions = {
|
||||
enquireLinkInterval?: number | undefined;
|
||||
idleTimeout?: number | undefined;
|
||||
log?: LogInt | undefined;
|
||||
maxOutstanding?: number | undefined;
|
||||
maxReassembly?: number | undefined;
|
||||
/**
|
||||
* First refusal on every incoming request. Returning true means the hook answered it and the
|
||||
* built-in handling is skipped — this is how the server owns bind without the session also
|
||||
* replying "invalid command".
|
||||
*/
|
||||
onRequest?: ((session: Session, pduObj: PduObject) => Promise<boolean>) | undefined;
|
||||
reassemblyTimeout?: number | undefined;
|
||||
reconnect?: ReconnectOptions | undefined;
|
||||
responseTimeout?: number | undefined;
|
||||
sock: Socket;
|
||||
/** This end's own identity, answered to the peer in place of the one it sent. */
|
||||
systemId?: string | undefined;
|
||||
};
|
||||
|
||||
export const defaultSystemId = '';
|
||||
|
||||
export const defaults = {
|
||||
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
|
||||
dlrMergeTimeout: 86_400_000,
|
||||
maxDelay: 30_000,
|
||||
maxDlrMerges: 1000,
|
||||
maxOutstanding: 10,
|
||||
maxReassembly: 1000,
|
||||
minDelay: 1000,
|
||||
reassemblyTimeout: 300_000,
|
||||
responseTimeout: 30_000,
|
||||
systemId: defaultSystemId,
|
||||
};
|
||||
+38
-55
@@ -4,6 +4,7 @@ import type { LogInt } from '@larvit/log';
|
||||
import type { MessageDlr } from './dlr-merger.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||
import type { ReconnectOptions, SendOptions, SessionOptions } from './session-options.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
||||
import type { Sms } from './sms.ts';
|
||||
@@ -19,13 +20,15 @@ import { SendWindow } from './send-window.ts';
|
||||
import { concatInfo } from './udh.ts';
|
||||
import { consts, optionalParamsMinVersion } from './defs/constants.ts';
|
||||
import { createSms } from './sms.ts';
|
||||
import { defaultSystemId, defaults } from './session-options.ts';
|
||||
import { dlrFromPdu } from './dlr.ts';
|
||||
import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
|
||||
import { paramText } from './defs/types.ts';
|
||||
import { silentLog } from './log.ts';
|
||||
import { submitSms } from './send-sms.ts';
|
||||
|
||||
export type { MessageDlr, SendSmsOptions };
|
||||
export type { MessageDlr, ReconnectOptions, SendOptions, SendSmsOptions, SessionOptions };
|
||||
export { defaultSystemId };
|
||||
|
||||
export type SessionEvents = {
|
||||
close: [];
|
||||
@@ -39,51 +42,6 @@ export type SessionEvents = {
|
||||
sms: [Sms];
|
||||
};
|
||||
|
||||
export type SendOptions = { signal?: AbortSignal | undefined };
|
||||
|
||||
/**
|
||||
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller
|
||||
* supplies how to open a socket and what to do once it is open (bind, for a client).
|
||||
*/
|
||||
export type ReconnectOptions = {
|
||||
connect: () => Promise<Result<{ sock: Socket }>>;
|
||||
maxDelay?: number | undefined;
|
||||
minDelay?: number | undefined;
|
||||
onConnected: (session: Session) => Promise<VoidResult>;
|
||||
};
|
||||
|
||||
export type SessionOptions = {
|
||||
enquireLinkInterval?: number | undefined;
|
||||
idleTimeout?: number | undefined;
|
||||
log?: LogInt | undefined;
|
||||
maxOutstanding?: number | undefined;
|
||||
maxReassembly?: number | undefined;
|
||||
/**
|
||||
* First refusal on every incoming request. Returning true means the hook answered it and the
|
||||
* built-in handling is skipped — this is how the server owns bind without the session also
|
||||
* replying "invalid command".
|
||||
*/
|
||||
onRequest?: ((session: Session, pduObj: PduObject) => Promise<boolean>) | undefined;
|
||||
reassemblyTimeout?: number | undefined;
|
||||
reconnect?: ReconnectOptions | undefined;
|
||||
responseTimeout?: number | undefined;
|
||||
sock: Socket;
|
||||
/** This end's own identity, answered to the peer in place of the one it sent. */
|
||||
systemId?: string | undefined;
|
||||
};
|
||||
|
||||
export const defaultSystemId = '';
|
||||
|
||||
const defaults = {
|
||||
maxDelay: 30_000,
|
||||
maxOutstanding: 10,
|
||||
maxReassembly: 1000,
|
||||
minDelay: 1000,
|
||||
reassemblyTimeout: 300_000,
|
||||
responseTimeout: 30_000,
|
||||
systemId: defaultSystemId,
|
||||
};
|
||||
|
||||
export const bindCommands: readonly string[] = [
|
||||
'bind_receiver',
|
||||
'bind_transceiver',
|
||||
@@ -100,7 +58,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
peerInterfaceVersion: number | undefined = undefined;
|
||||
userData: unknown = undefined;
|
||||
|
||||
private readonly dlrMerger = new DlrMerger();
|
||||
private readonly dlrMerger: DlrMerger;
|
||||
private readonly options: SessionOptions;
|
||||
private readonly pending: PendingRequests;
|
||||
private readonly reassembler: Reassembler;
|
||||
@@ -117,6 +75,11 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
|
||||
this.log = options.log ?? silentLog;
|
||||
this.options = options;
|
||||
this.dlrMerger = new DlrMerger({
|
||||
log: this.log,
|
||||
max: defaults.maxDlrMerges,
|
||||
timeout: defaults.dlrMergeTimeout,
|
||||
});
|
||||
this.pending = new PendingRequests(this.log);
|
||||
this.reassembler = new Reassembler({
|
||||
log: this.log,
|
||||
@@ -130,7 +93,8 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
idleTimeout: options.idleTimeout,
|
||||
log: this.log,
|
||||
onEnquireLink: () => { void this.send({ cmdName: 'enquire_link' }); },
|
||||
onIdle: () => { this.close(); },
|
||||
// Not close(): a link that went quiet is a drop, and a drop is what reconnect is for.
|
||||
onIdle: () => { this.teardown(); },
|
||||
});
|
||||
this.window = new SendWindow(options.maxOutstanding ?? defaults.maxOutstanding);
|
||||
|
||||
@@ -172,10 +136,18 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
tlvs?: Record<string, TlvInput>,
|
||||
): Promise<VoidResult> {
|
||||
const built = pduReturn(pdu, status, params, tlvs);
|
||||
const sent = built.err ? { err: built.err } : this.write(built.buffer);
|
||||
|
||||
if (built.err) return { err: built.err };
|
||||
if (sent.err) {
|
||||
this.log.warn('session - could not answer a request', {
|
||||
cmdName: pdu.cmdName,
|
||||
message: sent.err.message,
|
||||
seqNr: pdu.seqNr,
|
||||
});
|
||||
this.emit('sessionError', sent.err);
|
||||
}
|
||||
|
||||
return Promise.resolve(this.write(built.buffer));
|
||||
return Promise.resolve(sent);
|
||||
}
|
||||
|
||||
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
|
||||
@@ -185,18 +157,20 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
send: input => this.send(input, options),
|
||||
}, sms);
|
||||
|
||||
if (!sent.err) this.dlrMerger.expect(sent.smsIds);
|
||||
if (!sent.err && sms.dlr === true) this.dlrMerger.expect(sent.smsIds);
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Unbinds politely, then closes. */
|
||||
/** Unbinds politely, then closes. Many SMSCs drop the link instead of answering, which is fine. */
|
||||
async unbind(): Promise<VoidResult> {
|
||||
const wasOpen = !this.closed;
|
||||
const sent = await this.send({ cmdName: 'unbind' });
|
||||
const closedOnUnbind = wasOpen && this.closed;
|
||||
|
||||
this.close();
|
||||
|
||||
return sent.err ? { err: sent.err } : {};
|
||||
return sent.err && !closedOnUnbind ? { err: sent.err } : {};
|
||||
}
|
||||
|
||||
/** Closes for good. A session closed this way never reconnects. */
|
||||
@@ -240,6 +214,9 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
|
||||
/** Wires a freshly opened socket into this session, replacing any previous one. */
|
||||
private attach(sock: Socket): void {
|
||||
// The socket being replaced is already dead, and its three handlers still point here.
|
||||
if (this.sock !== sock) this.sock.removeAllListeners();
|
||||
|
||||
this.sock = sock;
|
||||
this.framer = new PduFramer();
|
||||
this.closed = false;
|
||||
@@ -283,6 +260,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
this.closed = true;
|
||||
this.timers.clear();
|
||||
this.pending.settleAll(new Error('Session closed before a response arrived'));
|
||||
this.dlrMerger.clear();
|
||||
this.reassembler.clear();
|
||||
this.sock.destroy();
|
||||
this.emit('close');
|
||||
@@ -355,7 +333,13 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
}
|
||||
|
||||
this.emit('incomingPduObj', pduObj);
|
||||
void this.handle(pduObj);
|
||||
// Every application hook and listener reached from an incoming PDU funnels through here.
|
||||
void this.handle(pduObj).catch((thrown: unknown) => {
|
||||
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
|
||||
|
||||
this.log.error('session - a handler threw', { message: err.message });
|
||||
this.emit('sessionError', err);
|
||||
});
|
||||
}
|
||||
|
||||
private async handle(pduObj: PduObject): Promise<void> {
|
||||
@@ -385,7 +369,6 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
private async unhandled(pduObj: PduObject): Promise<void> {
|
||||
if (bindCommands.includes(pduObj.cmdName)) {
|
||||
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
|
||||
// Explicit, or pduReturn's echo answers the peer with its own system_id instead of ours.
|
||||
await this.sendReturn(pduObj, 'ESME_RALYBND', {
|
||||
system_id: this.options.systemId ?? defaults.systemId,
|
||||
});
|
||||
|
||||
+27
-8
@@ -8,6 +8,12 @@ import { receiptCodes } from './dlr.ts';
|
||||
import { smppDate } from './message.ts';
|
||||
import { uuidv7 } from './uuid.ts';
|
||||
|
||||
export type SendRespOptions = {
|
||||
/** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */
|
||||
smsId?: string;
|
||||
status?: ErrorName;
|
||||
};
|
||||
|
||||
/**
|
||||
* A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying
|
||||
* every segment's PDU.
|
||||
@@ -21,10 +27,10 @@ export type Sms = {
|
||||
/** Sends a delivery report back to the sender. Defaults to DELIVERED. */
|
||||
sendDlr: (status?: MessageState) => Promise<Result<{ pduObjs: PduObject[] }>>;
|
||||
/** Answers every segment. Part of the protocol, not optional. Defaults to ESME_ROK. */
|
||||
sendResp: (status?: ErrorName) => Promise<VoidResult>;
|
||||
sendResp: (options?: SendRespOptions) => Promise<VoidResult>;
|
||||
session: Session;
|
||||
/** Generated as a UUID v7 unless the application sets its own before answering. */
|
||||
smsId: string;
|
||||
/** The id answered to the peer: what sendResp() was given, or a generated UUID v7. */
|
||||
readonly smsId: string;
|
||||
submitTime: Date;
|
||||
to: string;
|
||||
};
|
||||
@@ -46,6 +52,7 @@ export function createSms(input: SmsInput): Sms {
|
||||
const first = input.pduObjs[0];
|
||||
const registered = first?.params.registered_delivery;
|
||||
const dataCoding = first?.params.data_coding;
|
||||
const answered = { smsId: uuidv7() };
|
||||
|
||||
const sms: Sms = {
|
||||
dlr: typeof registered === 'number' && registered !== 0,
|
||||
@@ -54,9 +61,11 @@ export function createSms(input: SmsInput): Sms {
|
||||
message: input.message,
|
||||
pduObjs: input.pduObjs,
|
||||
sendDlr: status => sendDlr(sms, status),
|
||||
sendResp: status => sendResp(sms, status),
|
||||
sendResp: options => sendResp(sms, answered, options ?? {}),
|
||||
session: input.session,
|
||||
smsId: uuidv7(),
|
||||
get smsId(): string {
|
||||
return answered.smsId;
|
||||
},
|
||||
submitTime: new Date(),
|
||||
to: input.to,
|
||||
};
|
||||
@@ -64,17 +73,27 @@ export function createSms(input: SmsInput): Sms {
|
||||
return sms;
|
||||
}
|
||||
|
||||
async function sendResp(sms: Sms, status: ErrorName = 'ESME_ROK'): Promise<VoidResult> {
|
||||
async function sendResp(
|
||||
sms: Sms,
|
||||
answered: { smsId: string },
|
||||
options: SendRespOptions,
|
||||
): Promise<VoidResult> {
|
||||
const total = sms.pduObjs.length;
|
||||
|
||||
if (total === 0) {
|
||||
return { err: new Error('No PDUs to answer') };
|
||||
}
|
||||
|
||||
if (options.smsId === '') {
|
||||
return { err: new Error('smsId must not be empty') };
|
||||
}
|
||||
|
||||
if (options.smsId !== undefined) answered.smsId = options.smsId;
|
||||
|
||||
const results = await Promise.all(sms.pduObjs.map((pduObj, index) => sms.session.sendReturn(
|
||||
pduObj,
|
||||
status,
|
||||
{ message_id: segmentId(sms.smsId, index, total) },
|
||||
options.status ?? 'ESME_ROK',
|
||||
{ message_id: segmentId(answered.smsId, index, total) },
|
||||
)));
|
||||
|
||||
return results.find(result => result.err) ?? {};
|
||||
|
||||
Reference in New Issue
Block a user