Split the session, gate optional parameters on the peer version, own the SMPP version in defs

This commit is contained in:
2026-08-26 22:41:28 +02:00
parent 1e5807f647
commit 6d842a8539
23 changed files with 1381 additions and 803 deletions
+8
View File
@@ -19,6 +19,14 @@ jobs:
node-version: 24 node-version: 24
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
- run: npm ci - run: npm ci
- name: The tag must match the version being published
run: |
tagged="${GITHUB_REF_NAME#v}"
packaged="$(node -p 'require("./package.json").version')"
test "$tagged" = "$packaged" || {
echo "tag $GITHUB_REF_NAME does not match package.json $packaged"
exit 1
}
- run: npm run lint - run: npm run lint
- run: npm test - run: npm test
- run: npm publish --provenance --access public - run: npm publish --provenance --access public
+20 -2
View File
@@ -38,14 +38,26 @@ src/
index.ts Public surface. Named exports only, no default export. index.ts Public surface. Named exports only, no default export.
client.ts client() -> { err, session } client.ts client() -> { err, session }
server.ts server() -> { err, server }, server owns the listener + close() server.ts server() -> { err, server }, server owns the listener + close()
session.ts Session: framing, sequence numbers, the send window, events session.ts Session: dispatch, events, and the collaborators below
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr) sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts silentLog — the default when the application passes none
message.ts Encoding detection, splitting, bit counting, SMPP date formatting message.ts Encoding detection, splitting, bit counting, SMPP date formatting
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort
reassembly.ts Reassembler: capped, expiring multipart groups
reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness
result.ts Result<T> — the shape every fallible call returns result.ts Result<T> — the shape every fallible call returns
send-sms.ts submitSms composition and the submitSmParams builder
send-window.ts SendWindow: the maxOutstanding semaphore
udh.ts User data header: the concatenation fields of a long SMS
uuid.ts uuidv7() — the ids the library generates for messages
defs/ defs/
commands.ts The 33 commands, their ids and ordered parameter lists commands.ts The 33 commands, their ids and ordered parameter lists
constants.ts consts + constsById (TON, NPI, ENCODING, MESSAGE_STATE, …) constants.ts consts + constsById, and the SMPP version constants
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
errors.ts errors + errorsById (ESME_*) errors.ts errors + errorsById (ESME_*)
tlvs.ts TLV definitions, tlvsById tlvs.ts TLV definitions, tlvsById
@@ -142,6 +154,12 @@ exactly 140.
## Decisions ## Decisions
- **The library speaks SMPP 3.4 on the wire, and `defs/` keeps the 5.0 tables as a superset.**
Maintainer's call, 2026-08-26: 3.4 is what SMSCs actually run, while the wider tables let the codec
parse and build whatever a peer sends. The declared version is an option on both `client()` and
`server()`, so an implementation that needs 5.0 throughout can have it. The threshold at or above
which a peer may be sent optional parameters is fixed at 0x34 by the spec and is not the same
constant as the declared version.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of - **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI `node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI
+6
View File
@@ -166,6 +166,8 @@ await smpp.close(); // stop listening and close every live session
| --- | --- | --- | | --- | --- | --- |
| `host` / `port` | all interfaces / `2775` | Where to listen. Pass `0` for any free port. | | `host` / `port` | all interfaces / `2775` | Where to listen. Pass `0` for any free port. |
| `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. | | `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. |
| `systemId` | `''` | The SMSC identity returned to the ESME in the bind response. |
| `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. The floor for sending a peer optional parameters stays `0x34`, whatever this is set to. |
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. | | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. | | `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. | | `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
@@ -275,6 +277,10 @@ have worked around any of these, remove the workaround:
- A message whose last octet was `0x00` was allocated one octet short while `sm_length` still - A message whose last octet was `0x00` was allocated one octet short while `sm_length` still
reported the full length, so it went out corrupt. In UCS2 that is any message ending in a reported the full length, so it went out corrupt. In UCS2 that is any message ending in a
character like 一 (U+4E00), which made the bug routine for CJK text. character like 一 (U+4E00), which made the bug routine for CJK text.
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed
into a hex string and written back as the ASCII of that string, so every one that made a round
trip went out corrupt. They are `Buffer`s in both directions now, so drop any hex encoding of
your own.
- Short or malformed PDUs threw out of the codec instead of being reported as a parse failure. - Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
- Binds now declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME - Binds now declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME
speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter, speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter,
+23
View File
@@ -24,6 +24,29 @@ export default tseslint.config(
'no-console': 'error', 'no-console': 'error',
}, },
}, },
{
files: ['src/**/*.ts'],
rules: {
complexity: ['error', 10],
'max-lines': ['error', { max: 350, skipBlankLines: true, skipComments: true }],
'max-lines-per-function': ['error', { max: 40, skipBlankLines: true, skipComments: true }],
'max-params': ['error', 5],
},
},
{
// The spec tables are data: their length tracks the specification, not any complexity.
files: ['src/defs/*.ts'],
rules: { 'max-lines': 'off' },
},
{
// The codec branches per wire type and per parameter; splitting it scatters the wire format
// across files instead. These two keep the ceiling they have today.
files: ['src/dlr.ts', 'src/pdu.ts'],
rules: {
complexity: ['error', 22],
'max-lines-per-function': ['error', { max: 75, skipBlankLines: true, skipComments: true }],
},
},
{ {
// ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns. // ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns.
files: ['src/defs/encodings.ts'], files: ['src/defs/encodings.ts'],
+19 -18
View File
@@ -5,6 +5,7 @@ import type { Socket } from 'node:net';
import { Session } from './session.ts'; import { Session } from './session.ts';
import { connect as netConnect } from 'node:net'; import { connect as netConnect } from 'node:net';
import { connect as tlsConnect } from 'node:tls'; import { connect as tlsConnect } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
import { silentLog } from './log.ts'; import { silentLog } from './log.ts';
export type BindType = 'receiver' | 'transceiver' | 'transmitter'; export type BindType = 'receiver' | 'transceiver' | 'transmitter';
@@ -33,8 +34,7 @@ const defaults = {
bindType: 'transceiver', bindType: 'transceiver',
enquireLinkInterval: 20_000, enquireLinkInterval: 20_000,
host: 'localhost', host: 'localhost',
/** SMPP 3.4. The 3.4 spec reserves every value above it, so 0x50 is undefined to a 3.4 SMSC. */ interfaceVersion: defaultInterfaceVersion,
interfaceVersion: 0x34,
password: 'pass', password: 'pass',
port: 2775, port: 2775,
username: 'user', username: 'user',
@@ -45,9 +45,12 @@ const defaults = {
* handshake, so those connections were not encrypted at all. * handshake, so those connections were not encrypted at all.
*/ */
function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> { function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
return new Promise(resolve => {
const host = options.host ?? defaults.host; const host = options.host ?? defaults.host;
const port = options.port ?? defaults.port; 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; const signal = options.signal;
if (signal?.aborted === true) { if (signal?.aborted === true) {
@@ -56,13 +59,7 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
return; return;
} }
const sock = options.tls === undefined || options.tls === false const sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port });
? netConnect({ host, port })
: tlsConnect({
host,
port,
...(typeof options.tls === 'object' ? options.tls : {}),
});
const settle = (result: Result<{ sock: Socket }>): void => { const settle = (result: Result<{ sock: Socket }>): void => {
sock.removeListener('error', onError); sock.removeListener('error', onError);
@@ -81,18 +78,14 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
sock.once('error', onError); sock.once('error', onError);
signal?.addEventListener('abort', onAbort, { once: true }); signal?.addEventListener('abort', onAbort, { once: true });
sock.once(options.tls === undefined || options.tls === false ? 'connect' : 'secureConnect', () => { sock.once(secure ? 'secureConnect' : 'connect', () => {
settle({ sock }); settle({ sock });
}); });
}); });
} }
async function bind(session: Session, options: ClientOptions): Promise<VoidResult> { function bindParams(options: ClientOptions, systemId: string) {
const bindType = options.bindType ?? defaults.bindType; return {
const systemId = options.username ?? defaults.username;
const sent = await session.send({
cmdName: `bind_${bindType}`,
params: {
address_range: options.addressRange ?? '', address_range: options.addressRange ?? '',
addr_npi: options.addrNpi ?? 0, addr_npi: options.addrNpi ?? 0,
addr_ton: options.addrTon ?? 0, addr_ton: options.addrTon ?? 0,
@@ -100,7 +93,15 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
password: options.password ?? defaults.password, password: options.password ?? defaults.password,
system_id: systemId, system_id: systemId,
system_type: options.systemType ?? '', system_type: options.systemType ?? '',
}, };
}
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: bindParams(options, systemId),
...(options.signal ? { signal: options.signal } : {}), ...(options.signal ? { signal: options.signal } : {}),
}); });
+6
View File
@@ -1,3 +1,9 @@
/** The version declared on the wire. The tables below cover 5.0, which is a superset of it. */
export const defaultInterfaceVersion = 0x34;
/** Spec rule, not a preference: a peer declaring less than 3.4 is sent no optional parameters. */
export const optionalParamsMinVersion = 0x34;
export const consts = { export const consts = {
BROADCAST_AREA_FORMAT: { BROADCAST_AREA_FORMAT: {
ALIAS: 0x00, ALIAS: 0x00,
+79 -83
View File
@@ -8,96 +8,92 @@ export type TlvDefinition = {
type: WireType; type: WireType;
}; };
type TlvSpec = { id: number; multiple?: boolean; type: WireType }; /** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */
const tlvSpecs = <T extends { [K in keyof T]: { id: number; multiple?: boolean; tag: K; type: WireType } }>(
definitions: T,
): T => definitions;
// Ordered by tag id, mirroring the SMPP 5.0 TLV table. // Ordered by tag id, mirroring the SMPP 5.0 TLV table.
const specs = { const specs = tlvSpecs({
dest_addr_subunit: { id: 0x0005, type: tlv.int8 }, dest_addr_subunit: { id: 0x0005, tag: 'dest_addr_subunit', type: tlv.int8 },
dest_network_type: { id: 0x0006, type: tlv.int8 }, dest_network_type: { id: 0x0006, tag: 'dest_network_type', type: tlv.int8 },
dest_bearer_type: { id: 0x0007, type: tlv.int8 }, dest_bearer_type: { id: 0x0007, tag: 'dest_bearer_type', type: tlv.int8 },
dest_telematics_id: { id: 0x0008, type: tlv.int16 }, dest_telematics_id: { id: 0x0008, tag: 'dest_telematics_id', type: tlv.int16 },
source_addr_subunit: { id: 0x000D, type: tlv.int8 }, source_addr_subunit: { id: 0x000D, tag: 'source_addr_subunit', type: tlv.int8 },
source_network_type: { id: 0x000E, type: tlv.int8 }, source_network_type: { id: 0x000E, tag: 'source_network_type', type: tlv.int8 },
source_bearer_type: { id: 0x000F, type: tlv.int8 }, source_bearer_type: { id: 0x000F, tag: 'source_bearer_type', type: tlv.int8 },
source_telematics_id: { id: 0x0010, type: tlv.int16 }, source_telematics_id: { id: 0x0010, tag: 'source_telematics_id', type: tlv.int16 },
qos_time_to_live: { id: 0x0017, type: tlv.int32 }, qos_time_to_live: { id: 0x0017, tag: 'qos_time_to_live', type: tlv.int32 },
payload_type: { id: 0x0019, type: tlv.int8 }, payload_type: { id: 0x0019, tag: 'payload_type', type: tlv.int8 },
additional_status_info_text: { id: 0x001D, type: tlv.cstring }, additional_status_info_text: { id: 0x001D, tag: 'additional_status_info_text', type: tlv.cstring },
receipted_message_id: { id: 0x001E, type: tlv.cstring }, receipted_message_id: { id: 0x001E, tag: 'receipted_message_id', type: tlv.cstring },
ms_msg_wait_facilities: { id: 0x0030, type: tlv.int8 }, ms_msg_wait_facilities: { id: 0x0030, tag: 'ms_msg_wait_facilities', type: tlv.int8 },
privacy_indicator: { id: 0x0201, type: tlv.int8 }, privacy_indicator: { id: 0x0201, tag: 'privacy_indicator', type: tlv.int8 },
source_subaddress: { id: 0x0202, type: tlv.buffer }, source_subaddress: { id: 0x0202, tag: 'source_subaddress', type: tlv.buffer },
dest_subaddress: { id: 0x0203, type: tlv.buffer }, dest_subaddress: { id: 0x0203, tag: 'dest_subaddress', type: tlv.buffer },
user_message_reference: { id: 0x0204, type: tlv.int16 }, user_message_reference: { id: 0x0204, tag: 'user_message_reference', type: tlv.int16 },
user_response_code: { id: 0x0205, type: tlv.int8 }, user_response_code: { id: 0x0205, tag: 'user_response_code', type: tlv.int8 },
source_port: { id: 0x020A, type: tlv.int16 }, source_port: { id: 0x020A, tag: 'source_port', type: tlv.int16 },
dest_port: { id: 0x020B, type: tlv.int16 }, dest_port: { id: 0x020B, tag: 'dest_port', type: tlv.int16 },
sar_msg_ref_num: { id: 0x020C, type: tlv.int16 }, sar_msg_ref_num: { id: 0x020C, tag: 'sar_msg_ref_num', type: tlv.int16 },
language_indicator: { id: 0x020D, type: tlv.int8 }, language_indicator: { id: 0x020D, tag: 'language_indicator', type: tlv.int8 },
sar_total_segments: { id: 0x020E, type: tlv.int8 }, sar_total_segments: { id: 0x020E, tag: 'sar_total_segments', type: tlv.int8 },
sar_segment_seqnum: { id: 0x020F, type: tlv.int8 }, sar_segment_seqnum: { id: 0x020F, tag: 'sar_segment_seqnum', type: tlv.int8 },
sc_interface_version: { id: 0x0210, type: tlv.int8 }, sc_interface_version: { id: 0x0210, tag: 'sc_interface_version', type: tlv.int8 },
callback_num_pres_ind: { id: 0x0302, multiple: true, type: tlv.int8 }, callback_num_pres_ind: { id: 0x0302, multiple: true, tag: 'callback_num_pres_ind', type: tlv.int8 },
callback_num_atag: { id: 0x0303, multiple: true, type: tlv.buffer }, callback_num_atag: { id: 0x0303, multiple: true, tag: 'callback_num_atag', type: tlv.buffer },
number_of_messages: { id: 0x0304, type: tlv.int8 }, number_of_messages: { id: 0x0304, tag: 'number_of_messages', type: tlv.int8 },
callback_num: { id: 0x0381, multiple: true, type: tlv.buffer }, callback_num: { id: 0x0381, multiple: true, tag: 'callback_num', type: tlv.buffer },
dpf_result: { id: 0x0420, type: tlv.int8 }, dpf_result: { id: 0x0420, tag: 'dpf_result', type: tlv.int8 },
set_dpf: { id: 0x0421, type: tlv.int8 }, set_dpf: { id: 0x0421, tag: 'set_dpf', type: tlv.int8 },
ms_availability_status: { id: 0x0422, type: tlv.int8 }, ms_availability_status: { id: 0x0422, tag: 'ms_availability_status', type: tlv.int8 },
network_error_code: { id: 0x0423, type: tlv.buffer }, network_error_code: { id: 0x0423, tag: 'network_error_code', type: tlv.buffer },
message_payload: { id: 0x0424, type: tlv.buffer }, message_payload: { id: 0x0424, tag: 'message_payload', type: tlv.buffer },
delivery_failure_reason: { id: 0x0425, type: tlv.int8 }, delivery_failure_reason: { id: 0x0425, tag: 'delivery_failure_reason', type: tlv.int8 },
more_messages_to_send: { id: 0x0426, type: tlv.int8 }, more_messages_to_send: { id: 0x0426, tag: 'more_messages_to_send', type: tlv.int8 },
message_state: { id: 0x0427, type: tlv.int8 }, message_state: { id: 0x0427, tag: 'message_state', type: tlv.int8 },
congestion_state: { id: 0x0428, type: tlv.int8 }, congestion_state: { id: 0x0428, tag: 'congestion_state', type: tlv.int8 },
ussd_service_op: { id: 0x0501, type: tlv.int8 }, ussd_service_op: { id: 0x0501, tag: 'ussd_service_op', type: tlv.int8 },
broadcast_channel_indicator: { id: 0x0600, type: tlv.int8 }, broadcast_channel_indicator: { id: 0x0600, tag: 'broadcast_channel_indicator', type: tlv.int8 },
broadcast_content_type: { id: 0x0601, type: tlv.buffer }, broadcast_content_type: { id: 0x0601, tag: 'broadcast_content_type', type: tlv.buffer },
broadcast_content_type_info: { id: 0x0602, type: tlv.string }, broadcast_content_type_info: { id: 0x0602, tag: 'broadcast_content_type_info', type: tlv.string },
broadcast_message_class: { id: 0x0603, type: tlv.int8 }, broadcast_message_class: { id: 0x0603, tag: 'broadcast_message_class', type: tlv.int8 },
broadcast_rep_num: { id: 0x0604, type: tlv.int16 }, broadcast_rep_num: { id: 0x0604, tag: 'broadcast_rep_num', type: tlv.int16 },
broadcast_frequency_interval: { id: 0x0605, type: tlv.buffer }, broadcast_frequency_interval: { id: 0x0605, tag: 'broadcast_frequency_interval', type: tlv.buffer },
broadcast_area_identifier: { id: 0x0606, multiple: true, type: tlv.buffer }, broadcast_area_identifier: { id: 0x0606, multiple: true, tag: 'broadcast_area_identifier', type: tlv.buffer },
broadcast_error_status: { id: 0x0607, multiple: true, type: tlv.int32 }, broadcast_error_status: { id: 0x0607, multiple: true, tag: 'broadcast_error_status', type: tlv.int32 },
broadcast_area_success: { id: 0x0608, type: tlv.int8 }, broadcast_area_success: { id: 0x0608, tag: 'broadcast_area_success', type: tlv.int8 },
broadcast_end_time: { id: 0x0609, type: tlv.string }, broadcast_end_time: { id: 0x0609, tag: 'broadcast_end_time', type: tlv.string },
broadcast_service_group: { id: 0x060A, type: tlv.string }, broadcast_service_group: { id: 0x060A, tag: 'broadcast_service_group', type: tlv.string },
billing_identification: { id: 0x060B, type: tlv.buffer }, billing_identification: { id: 0x060B, tag: 'billing_identification', type: tlv.buffer },
source_network_id: { id: 0x060D, type: tlv.cstring }, source_network_id: { id: 0x060D, tag: 'source_network_id', type: tlv.cstring },
dest_network_id: { id: 0x060E, type: tlv.cstring }, dest_network_id: { id: 0x060E, tag: 'dest_network_id', type: tlv.cstring },
source_node_id: { id: 0x060F, type: tlv.string }, source_node_id: { id: 0x060F, tag: 'source_node_id', type: tlv.string },
dest_node_id: { id: 0x0610, type: tlv.string }, dest_node_id: { id: 0x0610, tag: 'dest_node_id', type: tlv.string },
dest_addr_np_resolution: { id: 0x0611, type: tlv.int8 }, dest_addr_np_resolution: { id: 0x0611, tag: 'dest_addr_np_resolution', type: tlv.int8 },
dest_addr_np_information: { id: 0x0612, type: tlv.string }, dest_addr_np_information: { id: 0x0612, tag: 'dest_addr_np_information', type: tlv.string },
dest_addr_np_country: { id: 0x0613, type: tlv.int32 }, dest_addr_np_country: { id: 0x0613, tag: 'dest_addr_np_country', type: tlv.int32 },
display_time: { id: 0x1201, type: tlv.int8 }, display_time: { id: 0x1201, tag: 'display_time', type: tlv.int8 },
sms_signal: { id: 0x1203, type: tlv.int16 }, sms_signal: { id: 0x1203, tag: 'sms_signal', type: tlv.int16 },
ms_validity: { id: 0x1204, type: tlv.buffer }, ms_validity: { id: 0x1204, tag: 'ms_validity', type: tlv.buffer },
alert_on_message_delivery: { id: 0x130C, type: tlv.int8 }, alert_on_message_delivery: { id: 0x130C, tag: 'alert_on_message_delivery', type: tlv.int8 },
its_reply_type: { id: 0x1380, type: tlv.int8 }, its_reply_type: { id: 0x1380, tag: 'its_reply_type', type: tlv.int8 },
its_session_info: { id: 0x1383, type: tlv.buffer }, its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer },
} satisfies Record<string, TlvSpec>; });
export type TlvName = keyof typeof specs; export type TlvName = keyof typeof specs;
export const tlvs: Record<string, TlvDefinition> = {}; export const tlvs: Record<TlvName, TlvDefinition> & Record<string, TlvDefinition> = {
export const tlvsById: Record<number, TlvDefinition> = {}; ...specs,
// Alternate spellings; the definition behind each keeps its canonical name.
for (const [tag, spec] of Object.entries<TlvSpec>(specs)) { alert_on_msg_delivery: specs.alert_on_message_delivery,
const definition: TlvDefinition = { ...spec, tag }; failed_broadcast_area_identifier: specs.broadcast_area_identifier,
tlvs[tag] = definition;
tlvsById[spec.id] = definition;
}
// Alternate spellings that resolve to the same tag; the definition keeps its canonical name.
const aliases: Record<string, TlvName> = {
alert_on_msg_delivery: 'alert_on_message_delivery',
failed_broadcast_area_identifier: 'broadcast_area_identifier',
}; };
for (const [alias, target] of Object.entries(aliases)) { export const tlvsById: Record<number, TlvDefinition> = {};
tlvs[alias] = { ...specs[target], tag: target };
for (const definition of Object.values<TlvDefinition>(specs)) {
tlvsById[definition.id] = definition;
} }
/** Fallback for tags this table does not know: keep the raw octets. */ /** Fallback for tags this table does not know: keep the raw octets. */
+64
View File
@@ -0,0 +1,64 @@
import type { Dlr } from './dlr.ts';
export type MessageDlr = Dlr & { segments: Dlr[] };
type Group = {
expected: number;
parts: Map<number, Dlr>;
};
const numbered = /^(.*)-(\d+)$/;
/**
* Merges the per-segment receipts of a multipart message into one report, but only when the peer
* numbered its ids `<base>-<n>` off one base — the convention this library's own server follows. An
* 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>();
/** Registers the ids one multipart send got back, so their receipts can be merged. */
expect(smsIds: string[]): void {
if (smsIds.length < 2) return;
const bases = new Set<string>();
for (const smsId of smsIds) {
const base = numbered.exec(smsId)?.[1];
if (base === undefined || base === '') return;
bases.add(base);
}
if (bases.size !== 1) return;
for (const base of bases) {
this.groups.set(base, { expected: smsIds.length, parts: new Map() });
}
}
/** The whole message's report, on the receipt that completes it. */
collect(dlr: Dlr): MessageDlr | undefined {
const match = numbered.exec(dlr.smsId);
const base = match?.[1];
const part = match?.[2];
if (base === undefined || part === undefined) return undefined;
const group = this.groups.get(base);
if (!group) return undefined;
group.parts.set(Number(part), dlr);
if (group.parts.size < group.expected) return undefined;
this.groups.delete(base);
const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one);
const worst = segments.reduce((carry, one) => (one.statusId > carry.statusId ? one : carry));
return { ...worst, segments, smsId: base };
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { LogInt } from '@larvit/log';
export type LinkTimersOptions = {
/** How long between enquire_link probes. Undefined or 0 never probes. */
enquireLinkInterval?: number | undefined;
/** How long a silent peer is kept. Undefined or 0 keeps it forever. */
idleTimeout?: number | undefined;
log: LogInt;
onEnquireLink: () => void;
onIdle: () => void;
};
/** Keeps a quiet connection honest: probes the peer, and gives up on one that stays silent. */
export class LinkTimers {
private readonly options: LinkTimersOptions;
private enquireLink: NodeJS.Timeout | undefined;
private idle: NodeJS.Timeout | undefined;
constructor(options: LinkTimersOptions) {
this.options = options;
}
/** Starts both timers over, which every sign of life from the peer should do. */
reset(): void {
const { enquireLinkInterval, idleTimeout, log, onEnquireLink, onIdle } = this.options;
this.clear();
if (enquireLinkInterval !== undefined && enquireLinkInterval > 0) {
this.enquireLink = setTimeout(onEnquireLink, enquireLinkInterval);
this.enquireLink.unref();
}
if (idleTimeout !== undefined && idleTimeout > 0) {
this.idle = setTimeout(() => {
log.info('linkTimers - closing an idle peer', { idleTimeout });
onIdle();
}, idleTimeout);
this.idle.unref();
}
}
clear(): void {
if (this.enquireLink) clearTimeout(this.enquireLink);
if (this.idle) clearTimeout(this.idle);
this.enquireLink = undefined;
this.idle = undefined;
}
}
+28 -6
View File
@@ -8,7 +8,7 @@ import { consts } from './defs/constants.ts';
import { decodeMessage, encodeMessage } from './message.ts'; import { decodeMessage, encodeMessage } from './message.ts';
import { detect, encodingByDataCoding } from './defs/encodings.ts'; import { detect, encodingByDataCoding } from './defs/encodings.ts';
import { errorNameById, errors, isErrorName } from './defs/errors.ts'; import { errorNameById, errors, isErrorName } from './defs/errors.ts';
import { tlvDefault, tlvsById } from './defs/tlvs.ts'; import { tlvDefault, tlvs, tlvsById } from './defs/tlvs.ts';
/** Sequence numbers are a 31-bit field; 0x7fffffff is reserved. */ /** Sequence numbers are a 31-bit field; 0x7fffffff is reserved. */
export const maxSeqNr = 2147483646; export const maxSeqNr = 2147483646;
@@ -17,8 +17,8 @@ export const maxSeqNr = 2147483646;
export const maxPduLength = 1024 * 1024; export const maxPduLength = 1024 * 1024;
export type TlvInput = { export type TlvInput = {
tagId: number; /** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagName?: string | undefined; tagId?: number | undefined;
tagValue: ParamValue; tagValue: ParamValue;
}; };
@@ -65,6 +65,20 @@ function numberOr(value: ParamValue | undefined, fallback: number): number {
return typeof value === 'number' ? value : fallback; return typeof value === 'number' ? value : fallback;
} }
function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
const tagId = input.tagId ?? tlvs[name]?.id;
if (tagId === undefined) {
return { err: new Error(`TLV "${name}": unknown tag name, give it a tagId`) };
}
if (!Number.isInteger(tagId) || tagId < 0 || tagId > 0xFFFF) {
return { err: new Error(`TLV "${name}": tagId ${String(tagId)} out of range 0-65535`) };
}
return { tagId };
}
function buildPdu( function buildPdu(
cmdName: CommandName, cmdName: CommandName,
cmdStatus: ErrorName, cmdStatus: ErrorName,
@@ -126,16 +140,24 @@ function buildPdu(
} }
for (const [name, tlv] of Object.entries(tlvs ?? {})) { for (const [name, tlv] of Object.entries(tlvs ?? {})) {
const type = tlvsById[tlv.tagId]?.type ?? tlvDefault; const tag = tagIdOf(name, tlv);
if (tag.err) return { err: tag.err };
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
const sized = type.size(tlv.tagValue); const sized = type.size(tlv.tagValue);
if (sized.err) { if (sized.err) {
return { err: new Error(`TLV "${name}": ${sized.err.message}`) }; return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
} }
if (sized.size > 0xffff) {
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
}
const chunk = Buffer.alloc(sized.size + 4); const chunk = Buffer.alloc(sized.size + 4);
chunk.writeUInt16BE(tlv.tagId, 0); chunk.writeUInt16BE(tag.tagId, 0);
chunk.writeUInt16BE(sized.size, 2); chunk.writeUInt16BE(sized.size, 2);
const written = type.write(tlv.tagValue, chunk, 4); const written = type.write(tlv.tagValue, chunk, 4);
@@ -192,7 +214,7 @@ function parseTlvs(
tlvs[definition?.tag ?? tagId.toString()] = { tlvs[definition?.tag ?? tagId.toString()] = {
tagId, tagId,
tagName: definition?.tag, tagName: definition?.tag,
tagValue: Buffer.isBuffer(read.value) ? read.value.toString('hex') : read.value, tagValue: read.value,
}; };
offset += 4 + tagLength; offset += 4 + tagLength;
+88
View File
@@ -0,0 +1,88 @@
import type { LogInt } from '@larvit/log';
import type { PduObject } from './pdu.ts';
import type { Result } from './result.ts';
import { maxSeqNr } from './pdu.ts';
export type WaitOptions = {
signal?: AbortSignal | undefined;
timeout: number;
};
type Pending = {
settle: (result: Result<{ pduObj: PduObject }>) => void;
};
/** Hands out sequence numbers and matches responses to the requests waiting for them. */
export class PendingRequests {
private readonly log: LogInt;
private readonly pending = new Map<number, Pending>();
private ourSeqNr = 1;
constructor(log: LogInt) {
this.log = log;
}
nextSeqNr(): number {
const seqNr = this.ourSeqNr;
this.ourSeqNr = this.ourSeqNr >= maxSeqNr ? 1 : this.ourSeqNr + 1;
return seqNr;
}
wait(seqNr: number, options: WaitOptions): Promise<Result<{ pduObj: PduObject }>> {
const { signal, timeout } = options;
return new Promise(resolve => {
const abort = (): void => {
this.settle(seqNr, { err: new Error('Aborted before a response arrived') });
};
const timer = timeout > 0 ? this.expire(seqNr, timeout) : undefined;
this.pending.set(seqNr, {
settle: result => {
if (timer) clearTimeout(timer);
signal?.removeEventListener('abort', abort);
this.pending.delete(seqNr);
resolve(result);
},
});
if (signal?.aborted === true) abort();
else signal?.addEventListener('abort', abort, { once: true });
});
}
/** Hands a response to whoever is waiting for it. False means nobody was. */
deliver(pduObj: PduObject): boolean {
const pending = this.pending.get(pduObj.seqNr);
if (!pending) return false;
pending.settle({ pduObj });
return true;
}
settle(seqNr: number, result: Result<{ pduObj: PduObject }>): void {
this.pending.get(seqNr)?.settle(result);
}
settleAll(err: Error): void {
for (const [seqNr] of this.pending) {
this.settle(seqNr, { err });
}
}
private expire(seqNr: number, timeout: number): NodeJS.Timeout {
const timer = setTimeout(() => {
this.log.warn('pendingRequests - no response before the timeout', { seqNr, timeout });
this.settle(seqNr, { err: new Error(`No response to seqNr ${String(seqNr)}`) });
}, timeout);
timer.unref();
return timer;
}
}
+139
View File
@@ -0,0 +1,139 @@
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 { decodeMessage } from './message.ts';
import { paramText } from './defs/types.ts';
export type ReassemblerOptions = {
log: LogInt;
max: number;
/** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined;
timeout: number;
};
type Group = {
deadline: number;
parts: Map<number, PduObject>;
total: number;
};
function groupKey(pduObj: PduObject, reference: number): string {
return [
paramText(pduObj.params.source_addr),
paramText(pduObj.params.destination_addr),
String(reference),
].join('_');
}
function numberOr(value: ParamValue | undefined, fallback: number): number {
return typeof value === 'number' ? value : fallback;
}
/** The text of a message, joining its segments in the order they were reassembled. */
export function decodeSegments(pduObjs: PduObject[]): string {
let message = '';
for (const pduObj of pduObjs) {
const part = pduObj.params.short_message;
message += Buffer.isBuffer(part)
? decodeMessage(
part,
numberOr(pduObj.params.data_coding, 0),
numberOr(pduObj.params.esm_class, 0),
).message
: paramText(part);
}
return message;
}
/** 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 log: LogInt;
private readonly max: number;
private readonly now: () => number;
private readonly timeout: number;
private sweeper: NodeJS.Timeout | undefined;
constructor(options: ReassemblerOptions) {
this.log = options.log;
this.max = options.max;
this.now = options.now ?? Date.now;
this.timeout = options.timeout;
}
get size(): number {
return this.groups.size;
}
/** Every segment in order, on the one that completes the message; nothing while it is short. */
collect(pduObj: PduObject, concat: ConcatInfo): PduObject[] | undefined {
this.sweep();
const key = groupKey(pduObj, concat.reference);
const group = this.groups.get(key) ?? this.open(key, concat.total);
group.parts.set(concat.part, pduObj);
if (group.parts.size < group.total) return undefined;
this.groups.delete(key);
this.idle();
return [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, part]) => part);
}
clear(): void {
this.groups.clear();
this.idle();
}
/** 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;
this.log.info('reassembler - incomplete message expired', { key, total: group.total });
this.groups.delete(key);
}
this.idle();
}
private open(key: string, total: number): Group {
if (this.groups.size >= this.max) this.dropOldest();
const group: Group = { deadline: this.now() + this.timeout, 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);
}
private idle(): void {
if (!this.sweeper || this.groups.size > 0) return;
clearInterval(this.sweeper);
this.sweeper = undefined;
}
}
+86
View File
@@ -0,0 +1,86 @@
import type { LogInt } from '@larvit/log';
import type { Result, VoidResult } from './result.ts';
import type { Socket } from 'node:net';
export type ReconnectLoopOptions = {
connect: () => Promise<Result<{ sock: Socket }>>;
log: LogInt;
maxDelay: number;
minDelay: number;
/** Brings the owner back up on a freshly opened socket. An err means try again. */
onConnected: (sock: Socket) => Promise<VoidResult>;
};
/** Reopens a dropped connection, backing off between attempts until it is told to stop. */
export class ReconnectLoop {
private readonly options: ReconnectLoopOptions;
private delay: number;
private halted = false;
private timer: NodeJS.Timeout | undefined;
constructor(options: ReconnectLoopOptions) {
this.options = options;
this.delay = options.minDelay;
}
/** Read through a method: stop() can land while an attempt is awaiting. */
isStopped(): boolean {
return this.halted;
}
schedule(): void {
if (this.timer || this.isStopped()) return;
const delay = this.delay;
this.options.log.info('reconnect - retrying after a drop', { delay });
this.timer = setTimeout(() => {
this.timer = undefined;
void this.run();
}, delay);
this.timer.unref();
this.delay = Math.min(delay * 2, this.options.maxDelay);
}
stop(): void {
this.halted = true;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
}
private async run(): Promise<void> {
if (this.isStopped()) return;
const opened = await this.options.connect();
if (opened.err) {
this.options.log.warn('reconnect - could not open a socket', {
message: opened.err.message,
});
this.schedule();
return;
}
if (this.isStopped()) {
opened.sock.destroy();
return;
}
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;
}
this.delay = this.options.minDelay;
}
}
+106
View File
@@ -0,0 +1,106 @@
import type { EncodingName } from './defs/encodings.ts';
import type { LogInt } from '@larvit/log';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput } from './pdu.ts';
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';
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 SendSmsResult = Result<{ pduObjs: PduObject[]; smsIds: string[] }>;
/** What sending needs from the session: a concat reference and a way onto the wire. */
export type SendSmsDeps = {
log: LogInt;
reference: number;
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
};
type SegmentOptions = {
encoding: EncodingName;
multipart: boolean;
};
/** 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 function submitSmParams(
sms: SendSmsOptions,
segment: Buffer,
options: SegmentOptions,
): Record<string, ParamValue> {
const params: Record<string, ParamValue> = {
data_coding: dataCodingFor(options.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 (options.multipart) params.esm_class = consts.ESM_CLASS.UDH_INDICATOR;
if (sms.dlr === true) params.registered_delivery = consts.REGISTERED_DELIVERY.FINAL;
if (sms.scheduleDeliveryTime !== undefined) {
params.schedule_delivery_time = smppTime.encode(sms.scheduleDeliveryTime);
}
if (sms.validityPeriod !== undefined) {
params.validity_period = smppTime.encode(sms.validityPeriod);
}
return params;
}
/** Puts a message on the wire as one submit_sm per segment. */
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 });
const multipart = segments.length > 1;
const pduObjs: PduObject[] = [];
const smsIds: string[] = [];
deps.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 => deps.send({
cmdName: 'submit_sm',
params: submitSmParams(sms, segment, { encoding, multipart }),
})));
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 };
}
+32
View File
@@ -0,0 +1,32 @@
/** Caps how many requests are on the wire at once; anything past the limit waits its turn. */
export class SendWindow {
private readonly limit: number;
private readonly waiting: (() => void)[] = [];
private inFlight = 0;
constructor(limit: number) {
this.limit = limit;
}
acquire(): Promise<void> {
if (this.inFlight < this.limit) {
this.inFlight++;
return Promise.resolve();
}
return new Promise<void>(resolve => this.waiting.push(resolve));
}
release(): void {
const next = this.waiting.shift();
if (next) {
next();
return;
}
this.inFlight--;
}
}
+85 -53
View File
@@ -4,12 +4,12 @@ import type { Result } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net'; import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls'; import type { Server as TlsServer, TlsOptions } from 'node:tls';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { Session } from './session.ts'; import { Session, bindCommands } from './session.ts';
import { createServer as createNetServer } from 'node:net'; import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls'; import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { silentLog } from './log.ts'; import { silentLog } from './log.ts';
import { tlvs } from './defs/tlvs.ts';
export type AuthenticateResult = { userData?: unknown } | boolean; export type AuthenticateResult = { userData?: unknown } | boolean;
@@ -24,6 +24,7 @@ export type ServerOptions = {
authenticate?: (input: AuthenticateInput) => Promise<AuthenticateResult> | AuthenticateResult; authenticate?: (input: AuthenticateInput) => Promise<AuthenticateResult> | AuthenticateResult;
host?: string; host?: string;
idleTimeout?: number; idleTimeout?: number;
interfaceVersion?: number;
log?: LogInt; log?: LogInt;
maxOutstanding?: number; maxOutstanding?: number;
maxReassembly?: number; maxReassembly?: number;
@@ -31,6 +32,7 @@ export type ServerOptions = {
reassemblyTimeout?: number; reassemblyTimeout?: number;
responseTimeout?: number; responseTimeout?: number;
signal?: AbortSignal; signal?: AbortSignal;
systemId?: string;
tls?: TlsOptions | boolean; tls?: TlsOptions | boolean;
}; };
@@ -41,12 +43,11 @@ export type ServerEvents = {
const defaults = { const defaults = {
idleTimeout: 40_000, idleTimeout: 40_000,
interfaceVersion: defaultInterfaceVersion,
port: 2775, port: 2775,
systemId: '',
}; };
const bindCommands = ['bind_receiver', 'bind_transceiver', 'bind_transmitter'];
const scInterfaceVersion = 0x34;
/** A listening SMPP server. Sessions arrive as `session` events; `close()` stops listening. */ /** A listening SMPP server. Sessions arrive as `session` events; `close()` stops listening. */
export class SmppServer extends EventEmitter<ServerEvents> { export class SmppServer extends EventEmitter<ServerEvents> {
readonly sessions = new Set<Session>(); readonly sessions = new Set<Session>();
@@ -102,25 +103,46 @@ async function authenticate(
return true; return true;
} }
/** /** An ESME reads a missing sc_interface_version as this SMSC having none. */
* A peer that declared below 0x34 must not be sent optional parameters at all; above it, an ESME function bindRespTlvs(session: Session, options: ServerOptions): Record<string, TlvInput> | undefined {
* reads a missing sc_interface_version as this SMSC having none. if (!session.acceptsOptionalParams()) return undefined;
*/
function bindRespTlvs(pduObj: PduObject): Record<string, TlvInput> | undefined {
const declared = pduObj.params.interface_version;
const definition = tlvs.sc_interface_version;
if (!definition || typeof declared !== 'number' || declared < scInterfaceVersion) return undefined;
return { return {
sc_interface_version: { sc_interface_version: { tagValue: options.interfaceVersion ?? defaults.interfaceVersion },
tagId: definition.id,
tagName: definition.tag,
tagValue: scInterfaceVersion,
},
}; };
} }
async function acceptBind(
session: Session,
pduObj: PduObject,
options: ServerOptions,
identity: Record<string, string>,
): Promise<void> {
const declared = pduObj.params.interface_version;
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number' ? declared : undefined;
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
}
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);
if (!await authenticate(session, pduObj, options)) {
log.info('server - bind refused', { systemId });
await session.sendReturn(pduObj, 'ESME_RBINDFAIL', identity);
return;
}
await acceptBind(session, pduObj, options, identity);
log.verbose('server - bound', { systemId });
}
/** /**
* Handles everything a peer may send before it is bound. Returns true when it has answered, so the * Handles everything a peer may send before it is bound. Returns true when it has answered, so the
* session leaves the PDU alone. * session leaves the PDU alone.
@@ -130,27 +152,18 @@ async function onRequest(
pduObj: PduObject, pduObj: PduObject,
options: ServerOptions, options: ServerOptions,
): Promise<boolean> { ): Promise<boolean> {
const log = options.log ?? silentLog;
if (session.loggedIn || pduObj.cmdName === 'unbind') return false; if (session.loggedIn || pduObj.cmdName === 'unbind') return false;
if (!bindCommands.includes(pduObj.cmdName)) { if (!bindCommands.includes(pduObj.cmdName)) {
const log = options.log ?? silentLog;
log.debug('server - command before bind', { cmdName: pduObj.cmdName }); log.debug('server - command before bind', { cmdName: pduObj.cmdName });
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
return true; return true;
} }
if (!await authenticate(session, pduObj, options)) { await onBind(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, 'ESME_ROK', {}, bindRespTlvs(pduObj));
log.verbose('server - bound', { systemId: paramText(pduObj.params.system_id) });
return true; return true;
} }
@@ -166,6 +179,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
reassemblyTimeout: options.reassemblyTimeout, reassemblyTimeout: options.reassemblyTimeout,
responseTimeout: options.responseTimeout, responseTimeout: options.responseTimeout,
sock, sock,
systemId: options.systemId ?? defaults.systemId,
}); });
server.sessions.add(session); server.sessions.add(session);
@@ -190,38 +204,28 @@ function createSecureListener(tlsOptions: TlsOptions, log: LogInt): TlsServer {
return listener; return listener;
} }
/** Starts listening for SMPP connections. Resolves once the socket is bound. */ function createListener(
export function server(options: ServerOptions = {}): Promise<Result<{ server: SmppServer }>> { options: ServerOptions,
return new Promise(resolve => { log: LogInt,
const log = options.log ?? silentLog; port: number,
const port = options.port ?? defaults.port; ): Result<{ listener: NetServer | TlsServer; useTls: boolean }> {
const useTls = options.tls !== undefined && options.tls !== false; const useTls = options.tls !== undefined && options.tls !== false;
const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined;
if (useTls && !tlsOptions) { if (useTls && !tlsOptions) {
log.warn('server - tls without a certificate', { port }); log.warn('server - tls without a certificate', { port });
resolve({ err: new Error('Listening over TLS needs tls: { cert, key }') });
return; return { err: new Error('Listening over TLS needs tls: { cert, key }') };
} }
const listener = tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(); return {
const smpp = new SmppServer(listener); listener: tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(),
useTls,
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); function onListening(listener: NetServer, smpp: SmppServer, options: ServerOptions): void {
const log = options.log ?? silentLog;
listener.listen(port, options.host, () => {
listener.removeListener('error', onStartupError);
// Past startup, a listener error is a runtime event, not a failed start. // Past startup, a listener error is a runtime event, not a failed start.
listener.on('error', (err: Error) => { listener.on('error', (err: Error) => {
@@ -234,7 +238,35 @@ export function server(options: ServerOptions = {}): Promise<Result<{ server: Sm
if (options.signal) { if (options.signal) {
options.signal.addEventListener('abort', () => { void smpp.close(); }, { once: true }); options.signal.addEventListener('abort', () => { void smpp.close(); }, { once: true });
} }
}
/** Starts listening for SMPP connections. Resolves once the socket is bound. */
export function server(options: ServerOptions = {}): Promise<Result<{ server: SmppServer }>> {
const log = options.log ?? silentLog;
const port = options.port ?? defaults.port;
const created = createListener(options, log, port);
if (created.err) return Promise.resolve({ err: created.err });
const listener = created.listener;
const smpp = new SmppServer(listener);
listener.on(created.useTls ? 'secureConnection' : 'connection', (sock: Socket) => {
onConnection(sock, options, smpp);
});
return new Promise(resolve => {
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);
onListening(listener, smpp, options);
resolve({ server: smpp }); resolve({ server: smpp });
}); });
}); });
+177 -477
View File
@@ -1,25 +1,31 @@
import type { Dlr } from './dlr.ts'; import type { Dlr } from './dlr.ts';
import type { EncodingName } from './defs/encodings.ts';
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { LogInt } from '@larvit/log'; import type { LogInt } from '@larvit/log';
import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts'; import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { Sms } from './sms.ts'; import type { Sms } from './sms.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { DlrMerger } from './dlr-merger.ts';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { LinkTimers } from './link-timers.ts';
import { PduFramer } from './pdu-framer.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 { concatInfo } from './udh.ts';
import { consts } from './defs/constants.ts'; import { consts, optionalParamsMinVersion } from './defs/constants.ts';
import { createSms } from './sms.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 { dlrFromPdu } from './dlr.ts';
import { isResp, maxSeqNr, objToPdu, pduReturn, pduToObj } from './pdu.ts'; import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { silentLog } from './log.ts'; import { silentLog } from './log.ts';
import { submitSms } from './send-sms.ts';
export type MessageDlr = Dlr & { segments: Dlr[] }; export type { MessageDlr, SendSmsOptions };
export type SessionEvents = { export type SessionEvents = {
close: []; close: [];
@@ -46,21 +52,6 @@ export type ReconnectOptions = {
onConnected: (session: Session) => Promise<VoidResult>; 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 = { export type SessionOptions = {
enquireLinkInterval?: number | undefined; enquireLinkInterval?: number | undefined;
idleTimeout?: number | undefined; idleTimeout?: number | undefined;
@@ -77,16 +68,8 @@ export type SessionOptions = {
reconnect?: ReconnectOptions | undefined; reconnect?: ReconnectOptions | undefined;
responseTimeout?: number | undefined; responseTimeout?: number | undefined;
sock: Socket; sock: Socket;
}; /** This end's own identity, answered to the peer in place of the one it sent. */
systemId?: string | undefined;
type Pending = {
settle: (result: Result<{ pduObj: PduObject }>) => void;
};
type Reassembly = {
parts: Map<number, PduObject>;
timer: NodeJS.Timeout;
total: number;
}; };
const defaults = { const defaults = {
@@ -96,19 +79,14 @@ const defaults = {
minDelay: 1000, minDelay: 1000,
reassemblyTimeout: 300_000, reassemblyTimeout: 300_000,
responseTimeout: 30_000, responseTimeout: 30_000,
systemId: '',
}; };
/** Alphanumeric senders must be TON 5; 0.4.0 sent everything as TON 1 (international). */ export const bindCommands: readonly string[] = [
function addressTon(address: string): number { 'bind_receiver',
return /^\+?\d+$/.test(address) ? consts.TON.INTERNATIONAL : consts.TON.ALPHANUMERIC; 'bind_transceiver',
} 'bind_transmitter',
];
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> { export class Session extends EventEmitter<SessionEvents> {
/** Replaced on reconnect, so hold the session rather than this. */ /** Replaced on reconnect, so hold the session rather than this. */
@@ -116,50 +94,52 @@ export class Session extends EventEmitter<SessionEvents> {
readonly log: LogInt; readonly log: LogInt;
loggedIn = false; loggedIn = false;
/** The interface_version the peer declared when binding; undefined until a bind is accepted. */
peerInterfaceVersion: number | undefined = undefined;
userData: unknown = undefined; userData: unknown = undefined;
private framer = new PduFramer(); private readonly dlrMerger = new DlrMerger();
private readonly options: SessionOptions; private readonly options: SessionOptions;
private readonly pending = new Map<number, Pending>(); private readonly pending: PendingRequests;
private readonly reassembly = new Map<string, Reassembly>(); private readonly reassembler: Reassembler;
private readonly segmentDlrs = new Map<string, { expected: number; parts: Map<number, Dlr> }>(); private readonly reconnectLoop: ReconnectLoop | undefined;
private readonly waiting: (() => void)[] = []; private readonly timers: LinkTimers;
private readonly window: SendWindow;
private closed = false; private closed = false;
private concatReference = 0; private concatReference = 0;
private enquireLinkTimer: NodeJS.Timeout | undefined; private framer = new PduFramer();
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) { constructor(options: SessionOptions) {
super(); super();
this.options = options;
this.log = options.log ?? silentLog; 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.sock = options.sock;
this.reconnectDelay = options.reconnect?.minDelay ?? defaults.minDelay; 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.attach(options.sock);
this.resetTimers(); this.resetTimers();
} }
/** Wires a freshly opened socket into this session, replacing any previous one. */ /** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
private attach(sock: Socket): void { acceptsOptionalParams(): boolean {
this.sock = sock; return this.peerInterfaceVersion === undefined
this.framer = new PduFramer(); || this.peerInterfaceVersion >= optionalParamsMinVersion;
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. */ /** Sends a request and resolves with the peer's response. */
@@ -173,26 +153,12 @@ export class Session extends EventEmitter<SessionEvents> {
if (this.closed) return { err: new Error('Session is closed') }; if (this.closed) return { err: new Error('Session is closed') };
await this.acquire(); await this.window.acquire();
try { try {
const seqNr = this.nextSeqNr(); return await this.request(input, options);
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 { } finally {
this.release(); this.window.release();
} }
} }
@@ -210,57 +176,16 @@ export class Session extends EventEmitter<SessionEvents> {
return Promise.resolve(this.write(built.buffer)); return Promise.resolve(this.write(built.buffer));
} }
async sendSms( async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
sms: SendSmsOptions, const sent = await submitSms({
options: SendOptions = {}, log: this.log,
): Promise<Result<{ pduObjs: PduObject[]; smsIds: string[] }>> {
const encoding = sms.encoding ?? detect(sms.message);
const segments = splitMessage(sms.message, {
encoding,
reference: this.nextConcatReference(), reference: this.nextConcatReference(),
}); send: input => this.send(input, options),
const pduObjs: PduObject[] = []; }, sms);
const smsIds: string[] = [];
this.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to }); if (!sent.err) this.dlrMerger.expect(sent.smsIds);
// Segments go out together rather than one-after-a-response: a receiver that waits for every return sent;
// 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));
}
this.expectSegmentDlrs(smsIds);
return { pduObjs, smsIds };
} }
/** Unbinds politely, then closes. */ /** Unbinds politely, then closes. */
@@ -274,71 +199,99 @@ export class Session extends EventEmitter<SessionEvents> {
/** Closes for good. A session closed this way never reconnects. */ /** Closes for good. A session closed this way never reconnects. */
close(): void { close(): void {
this.stopped = true; this.reconnectLoop?.stop();
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
this.teardown(); 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 { private teardown(): void {
if (this.closed) return; if (this.closed) return;
this.closed = true; this.closed = true;
this.clearTimers(); this.timers.clear();
this.pending.settleAll(new Error('Session closed before a response arrived'));
for (const [seqNr] of this.pending) { this.reassembler.clear();
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(); this.sock.destroy();
this.emit('close'); this.emit('close');
} }
private nextSeqNr(): number {
const seqNr = this.ourSeqNr;
this.ourSeqNr = this.ourSeqNr >= maxSeqNr ? 1 : this.ourSeqNr + 1;
return seqNr;
}
private nextConcatReference(): number { private nextConcatReference(): number {
this.concatReference = this.concatReference >= 255 ? 1 : this.concatReference + 1; this.concatReference = this.concatReference >= 255 ? 1 : this.concatReference + 1;
return this.concatReference; 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 { private write(pdu: Buffer): VoidResult {
if (this.sock.destroyed) { if (this.sock.destroyed) {
return { err: new Error('Socket is closed') }; return { err: new Error('Socket is closed') };
@@ -349,49 +302,6 @@ export class Session extends EventEmitter<SessionEvents> {
return {}; 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 { private onData(chunk: Buffer): void {
this.emit('data', chunk); this.emit('data', chunk);
this.resetTimers(); this.resetTimers();
@@ -408,6 +318,12 @@ export class Session extends EventEmitter<SessionEvents> {
} }
for (const pdu of framed.pdus) { 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); this.emit('incomingPdu', pdu);
const parsed = pduToObj(pdu); const parsed = pduToObj(pdu);
@@ -419,25 +335,20 @@ export class Session extends EventEmitter<SessionEvents> {
this.emit('sessionError', parsed.err); this.emit('sessionError', parsed.err);
this.close(); this.close();
return; return false;
} }
this.dispatch(parsed.pduObj); this.dispatch(parsed.pduObj);
}
return true;
} }
private dispatch(pduObj: PduObject): void { private dispatch(pduObj: PduObject): void {
if (isResp(pduObj)) { if (isResp(pduObj)) {
const pending = this.pending.get(pduObj.seqNr); if (!this.pending.deliver(pduObj)) {
if (!pending) {
this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr }); this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr });
return;
} }
pending.settle({ pduObj });
return; return;
} }
@@ -450,27 +361,32 @@ export class Session extends EventEmitter<SessionEvents> {
if (onRequest && await onRequest(this, pduObj)) return; if (onRequest && await onRequest(this, pduObj)) return;
if (pduObj.cmdName === 'enquire_link') { switch (pduObj.cmdName) {
case 'deliver_sm':
await this.onDeliverSm(pduObj);
break;
case 'enquire_link':
await this.sendReturn(pduObj); await this.sendReturn(pduObj);
break;
return; case 'submit_sm':
} this.onSubmitSm(pduObj);
break;
if (pduObj.cmdName === 'unbind') { case 'unbind':
await this.sendReturn(pduObj); await this.sendReturn(pduObj);
this.close(); this.close();
break;
return; default:
await this.unhandled(pduObj);
}
} }
if (pduObj.cmdName === 'submit_sm') { private async unhandled(pduObj: PduObject): Promise<void> {
this.onSubmitSm(pduObj); if (bindCommands.includes(pduObj.cmdName)) {
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
return; // 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,
if (pduObj.cmdName === 'deliver_sm') { });
await this.onDeliverSm(pduObj);
return; return;
} }
@@ -484,14 +400,7 @@ export class Session extends EventEmitter<SessionEvents> {
const esmClass = pduObj.params.esm_class; const esmClass = pduObj.params.esm_class;
const hasUdh = typeof esmClass === 'number' const hasUdh = typeof esmClass === 'number'
&& (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR; && (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR;
const concat = hasUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
if (!hasUdh || !Buffer.isBuffer(message)) {
this.emitSms([pduObj]);
return;
}
const concat = concatInfo(message);
if (!concat) { if (!concat) {
this.emitSms([pduObj]); this.emitSms([pduObj]);
@@ -499,66 +408,9 @@ export class Session extends EventEmitter<SessionEvents> {
return; return;
} }
this.collectSegment(pduObj, concat); const whole = this.reassembler.collect(pduObj, concat);
}
private collectSegment( if (whole) this.emitSms(whole);
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 { private emitSms(pduObjs: PduObject[]): void {
@@ -566,25 +418,9 @@ export class Session extends EventEmitter<SessionEvents> {
if (!first) return; 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({ this.emit('sms', createSms({
from: paramText(first.params.source_addr), from: paramText(first.params.source_addr),
message, message: decodeSegments(pduObjs),
pduObjs, pduObjs,
session: this, session: this,
to: paramText(first.params.destination_addr), to: paramText(first.params.destination_addr),
@@ -602,158 +438,22 @@ export class Session extends EventEmitter<SessionEvents> {
} }
this.emit('dlr', dlr, pduObj); this.emit('dlr', dlr, pduObj);
this.collectSegmentDlr(dlr);
const merged = this.dlrMerger.collect(dlr);
if (merged) this.emit('messageDlr', merged);
await this.sendReturn(pduObj); await this.sendReturn(pduObj);
} }
/**
* Registers a multipart message for merged reporting, but only when the peer numbered its ids
* `<base>-<n>` off one base — the convention this library's own server follows. An SMSC that
* hands out unrelated ids per segment cannot be merged, so no messageDlr is emitted for it.
*/
private expectSegmentDlrs(smsIds: string[]): void {
if (smsIds.length < 2) return;
const bases = new Set<string>();
for (const smsId of smsIds) {
const match = /^(.*)-(\d+)$/.exec(smsId);
if (!match?.[1]) return;
bases.add(match[1]);
}
if (bases.size !== 1) return;
for (const base of bases) {
this.segmentDlrs.set(base, { expected: smsIds.length, parts: new Map() });
}
}
/** Collects per-segment receipts and reports once on the whole message. */
private collectSegmentDlr(dlr: Dlr): void {
const match = /^(.*)-(\d+)$/.exec(dlr.smsId);
const base = match?.[1];
const part = match?.[2];
if (base === undefined || part === undefined) return;
const group = this.segmentDlrs.get(base);
if (!group) return;
group.parts.set(Number(part), dlr);
if (group.parts.size < group.expected) return;
this.segmentDlrs.delete(base);
const ordered = [...group.parts.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: base });
}
private resetTimers(): void { private resetTimers(): void {
if (this.closed) return; if (this.closed) return;
this.clearTimers(); this.timers.reset();
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 { private onClose(): void {
this.teardown(); this.teardown();
this.reconnectLoop?.schedule();
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');
} }
} }
+24 -21
View File
@@ -1,6 +1,6 @@
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { MessageState } from './defs/constants.ts'; import type { MessageState } from './defs/constants.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject, TlvInput } from './pdu.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts'; import type { Session } from './session.ts';
import { consts } from './defs/constants.ts'; import { consts } from './defs/constants.ts';
@@ -80,18 +80,11 @@ async function sendResp(sms: Sms, status: ErrorName = 'ESME_ROK'): Promise<VoidR
return results.find(result => result.err) ?? {}; return results.find(result => result.err) ?? {};
} }
async function sendDlr( /** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */
sms: Sms, function receiptText(sms: Sms, smsId: string, status: MessageState): string {
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 delivered = status === 'DELIVERED';
const message = [
return [
`id:${smsId}`, `id:${smsId}`,
'sub:001', 'sub:001',
`dlvrd:${delivered ? '001' : '000'}`, `dlvrd:${delivered ? '001' : '000'}`,
@@ -101,23 +94,33 @@ async function sendDlr(
`err:${delivered ? '000' : '001'}`, `err:${delivered ? '000' : '001'}`,
'text:', 'text:',
].join(' '); ].join(' ');
}
function receiptTlvs(smsId: string, status: MessageState): Record<string, TlvInput> {
return {
message_state: { tagValue: consts.MESSAGE_STATE[status] },
receipted_message_id: { tagValue: smsId },
};
}
async function sendDlr(
sms: Sms,
status: MessageState = 'DELIVERED',
): Promise<Result<{ pduObjs: PduObject[] }>> {
const total = sms.pduObjs.length;
const pduObjs: PduObject[] = [];
for (let index = 0; index < total; index++) {
const smsId = segmentId(sms.smsId, index, total);
const sent = await sms.session.send({ const sent = await sms.session.send({
cmdName: 'deliver_sm', cmdName: 'deliver_sm',
params: { params: {
destination_addr: sms.from, destination_addr: sms.from,
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: message, short_message: receiptText(sms, smsId, status),
source_addr: sms.to, source_addr: sms.to,
}, },
tlvs: { ...(sms.session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}),
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 }; if (sent.err) return { err: sent.err };
+2 -2
View File
@@ -61,8 +61,8 @@ describe('parseReceipt()', () => {
describe('dlrFromPdu()', () => { describe('dlrFromPdu()', () => {
test('prefers the TLVs when the peer sends them', () => { test('prefers the TLVs when the peer sends them', () => {
const dlr = dlrFromPdu(deliverSm(receiptText, { const dlr = dlrFromPdu(deliverSm(receiptText, {
message_state: { tagId: 0x0427, tagValue: 5 }, message_state: { tagValue: 5 },
receipted_message_id: { tagId: 0x001E, tagValue: 'from-the-tlv' }, receipted_message_id: { tagValue: 'from-the-tlv' },
})); }));
assert.ok(dlr); assert.ok(dlr);
+80 -12
View File
@@ -195,22 +195,90 @@ describe('TLVs', () => {
}, },
seqNr: 393, seqNr: 393,
tlvs: { tlvs: {
5142: { tagId: 5142, tagName: 'Nils', tagValue: Buffer.from('blajfoo', 'ascii') }, 5142: { tagId: 5142, tagValue: Buffer.from('blajfoo', 'ascii') },
receipted_message_id: { receipted_message_id: { tagValue: '293f293' },
tagId: 0x001E,
tagName: 'receipted_message_id',
tagValue: '293f293',
},
}, },
})); }));
assert.equal(pduObj.tlvs.receipted_message_id?.tagValue, '293f293'); assert.equal(pduObj.tlvs.receipted_message_id?.tagValue, '293f293');
assert.equal(pduObj.tlvs['5142']?.tagName, undefined);
const unknown = pduObj.tlvs['5142']?.tagValue; const unknown = pduObj.tlvs['5142'];
assert.equal(typeof unknown, 'string'); assert.ok(unknown);
assert.equal(Buffer.from(typeof unknown === 'string' ? unknown : '', 'hex').toString('ascii'), 'blajfoo'); assert.equal(unknown.tagName, undefined);
assert.deepEqual(unknown.tagValue, Buffer.from('blajfoo', 'ascii'));
});
test('keeps a binary TLV byte for byte through pduToObj and back', () => {
const payload = Buffer.from('deadbeef00ff', 'hex');
const params = {
destination_addr: '46709771337',
esm_class: 4,
short_message: 'binary payload follows',
source_addr: '46701113311',
};
const parsed = decode(encode({
cmdName: 'deliver_sm',
params,
seqNr: 7,
tlvs: { message_payload: { tagValue: payload } },
}));
const carried = parsed.tlvs.message_payload;
assert.ok(carried);
assert.deepEqual(carried.tagValue, payload);
const rebuilt = decode(encode({
cmdName: 'deliver_sm',
params,
seqNr: 7,
tlvs: { message_payload: { tagValue: carried.tagValue } },
}));
assert.deepEqual(rebuilt.tlvs.message_payload?.tagValue, payload);
});
test('takes the tag id from the record key when the caller gives none', () => {
const pduObj = decode(encode({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
seqNr: 11,
tlvs: { message_state: { tagValue: 6 }, source_port: { tagValue: 1234 } },
}));
assert.deepEqual(pduObj.tlvs.message_state, { tagId: 0x0427, tagName: 'message_state', tagValue: 6 });
assert.deepEqual(pduObj.tlvs.source_port, { tagId: 0x020A, tagName: 'source_port', tagValue: 1234 });
});
test('refuses an unknown tag name rather than putting a wrong tag on the wire', () => {
const { buffer, err } = objToPdu({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
tlvs: { nils: { tagValue: 'blajfoo' } },
});
assert.equal(buffer, undefined);
assert.ok(err instanceof Error);
});
test('refuses a tag id that does not fit the two octet field', () => {
const { err } = objToPdu({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
tlvs: { nils: { tagId: 0x10000, tagValue: 'blajfoo' } },
});
assert.ok(err instanceof Error);
});
test('refuses a TLV too long for the two octet length field', () => {
const { err } = objToPdu({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
tlvs: { message_payload: { tagValue: Buffer.alloc(0x10000) } },
});
assert.ok(err instanceof Error);
}); });
test('round-trips a receipt with message_state and receipted_message_id', () => { test('round-trips a receipt with message_state and receipted_message_id', () => {
@@ -225,8 +293,8 @@ describe('TLVs', () => {
}, },
seqNr: 323, seqNr: 323,
tlvs: { tlvs: {
message_state: { tagId: 1063, tagName: 'message_state', tagValue: 2 }, message_state: { tagId: 1063, tagValue: 2 },
receipted_message_id: { tagId: 30, tagName: 'receipted_message_id', tagValue: 450 }, receipted_message_id: { tagId: 30, tagValue: 450 },
}, },
})); }));
+54 -70
View File
@@ -2,11 +2,13 @@ import assert from 'node:assert/strict';
import net from 'node:net'; import net from 'node:net';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import type { MessageDlr } from '../src/session.ts'; import type { MessageDlr } from '../src/session.ts';
import type { PduObject } from '../src/pdu.ts';
import type { Sms } from '../src/sms.ts'; import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts'; import type { SmppServer } from '../src/server.ts';
import { Reassembler } from '../src/reassembly.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
import { objToPdu } from '../src/pdu.ts';
import { server } from '../src/server.ts'; import { server } from '../src/server.ts';
import { silentLog } from '../src/log.ts';
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> { async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
const { err, server: smpp } = await server({ ...options, port: 0 }); const { err, server: smpp } = await server({ ...options, port: 0 });
@@ -171,95 +173,77 @@ describe('reconnect', () => {
}); });
describe('reassembly bounds', () => { describe('reassembly bounds', () => {
function segment(reference: number, part: number, total: number, seqNr: number): Buffer { function segment(reference: number, part: number, total: number): PduObject {
const body = Buffer.concat([ const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
Buffer.from([0x05, 0x00, 0x03, reference, total, part]),
Buffer.from('fragment'), return {
]); cmdId: 0x00000004,
const { buffer } = objToPdu({ cmdLength: 0,
cmdName: 'submit_sm', cmdName: 'submit_sm',
cmdStatus: 'ESME_ROK',
cmdStatusId: 0,
params: { params: {
data_coding: 0, data_coding: 0,
destination_addr: '46709771337', destination_addr: '46709771337',
esm_class: 0x40, esm_class: 0x40,
short_message: body, short_message: Buffer.concat([udh, Buffer.from('fragment')]),
sm_length: body.length,
source_addr: '46701113311', source_addr: '46701113311',
}, },
seqNr, seqNr: part,
}); tlvs: {},
};
assert.ok(buffer);
return buffer;
} }
function collect(
reassembler: Reassembler,
reference: number,
part: number,
total: number,
): PduObject[] | undefined {
return reassembler.collect(segment(reference, part, total), { part, reference, total });
}
test('hands back every segment in order once the last one arrives', () => {
const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 });
assert.equal(collect(reassembler, 4, 2, 3), undefined);
assert.equal(collect(reassembler, 4, 3, 3), undefined);
const whole = collect(reassembler, 4, 1, 3);
assert.ok(whole);
assert.deepEqual(whole.map(pduObj => pduObj.seqNr), [1, 2, 3]);
assert.equal(reassembler.size, 0);
});
// 0.4.0 held incomplete groups without limit and swept them only when other traffic arrived. // 0.4.0 held incomplete groups without limit and swept them only when other traffic arrived.
test('drops the oldest incomplete message once the cap is reached', async () => { test('drops the oldest incomplete message once the cap is reached', () => {
const smpp = await startServer({ maxReassembly: 2, reassemblyTimeout: 60_000 }); const reassembler = new Reassembler({ log: silentLog, max: 2, now: () => 0, timeout: 60_000 });
let delivered = 0; for (const reference of [1, 2, 3]) {
assert.equal(collect(reassembler, reference, 1, 2), undefined);
}
smpp.on('session', session => session.on('sms', () => { delivered++; }));
const sock = net.connect({ port: smpp.port }, () => {
sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex'));
});
let bound = false;
sock.on('data', () => {
if (bound) return;
bound = true;
// Three different messages, each only ever sending part 1 of 2.
sock.write(segment(1, 1, 2, 10));
sock.write(segment(2, 1, 2, 11));
sock.write(segment(3, 1, 2, 12));
// Completing the first one must not produce a message: it was evicted. // Completing the first one must not produce a message: it was evicted.
sock.write(segment(1, 2, 2, 13)); assert.equal(collect(reassembler, 1, 2, 2), undefined);
assert.equal(reassembler.size, 2);
reassembler.clear();
}); });
await new Promise(resolve => setTimeout(resolve, 200)); test('expires an incomplete message once its timeout has passed', () => {
let now = 0;
const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => now, timeout: 60 });
assert.equal(delivered, 0, 'an evicted message must not be delivered'); assert.equal(collect(reassembler, 9, 1, 2), undefined);
sock.destroy(); now = 61;
await smpp.close();
});
test('expires an incomplete message on its own timer', async () => {
const smpp = await startServer({ reassemblyTimeout: 60 });
let delivered = 0;
smpp.on('session', session => session.on('sms', () => { delivered++; }));
const sock = net.connect({ port: smpp.port }, () => {
sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex'));
});
let bound = false;
sock.on('data', () => {
if (bound) return;
bound = true;
sock.write(segment(9, 1, 2, 20));
});
await new Promise(resolve => setTimeout(resolve, 200));
// The other half arrives after the group expired, so it starts a new, still-incomplete one. // The other half arrives after the group expired, so it starts a new, still-incomplete one.
sock.write(segment(9, 2, 2, 21)); assert.equal(collect(reassembler, 9, 2, 2), undefined);
assert.equal(reassembler.size, 1);
await new Promise(resolve => setTimeout(resolve, 100)); reassembler.clear();
assert.equal(delivered, 0);
sock.destroy();
await smpp.close();
}); });
}); });
+159 -18
View File
@@ -1,12 +1,14 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import net from 'node:net'; import net from 'node:net';
import test, { describe } from 'node:test'; import test, { describe } from 'node:test';
import type { PduObject } from '../src/pdu.ts'; import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Session } from '../src/session.ts'; import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts'; import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts'; import type { SmppServer } from '../src/server.ts';
import { PduFramer } from '../src/pdu-framer.ts';
import { client } from '../src/client.ts'; import { client } from '../src/client.ts';
import { isCommand, objToPdu, pduToObj } from '../src/pdu.ts'; import { isCommand, objToPdu, pduToObj } from '../src/pdu.ts';
import { paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts'; import { server } from '../src/server.ts';
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> { async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
@@ -26,28 +28,73 @@ function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>(resolve => { register(resolve); }); return new Promise<T>(resolve => { register(resolve); });
} }
/** Binds off a raw socket, which is the only way to declare a version the client cannot. */ type RawPeer = {
async function bindRaw(smpp: SmppServer, interfaceVersion: number): Promise<PduObject> { close: () => void;
const { buffer } = objToPdu({ /** The next PDU the server sends, queued so none is missed between reads. */
cmdName: 'bind_transceiver', next: () => Promise<PduObject>;
params: { interface_version: interfaceVersion, password: 'pass', system_id: 'user' }, write: (input: PduObjectInput) => void;
};
/** A peer driven PDU by PDU, which is the only way to say things the client never says. */
function rawPeer(port: number): RawPeer {
const framer = new PduFramer();
const queue: PduObject[] = [];
const waiting: ((pduObj: PduObject) => void)[] = [];
const sock = net.connect({ port });
sock.on('data', chunk => {
framer.push(chunk);
const { pdus } = framer.next();
for (const pdu of pdus ?? []) {
const { pduObj } = pduToObj(pdu);
if (!pduObj) continue;
const next = waiting.shift();
if (next) next(pduObj);
else queue.push(pduObj);
}
}); });
return {
close: () => { sock.destroy(); },
next: () => {
const queued = queue.shift();
return queued
? Promise.resolve(queued)
: once<PduObject>(resolve => waiting.push(resolve));
},
write: input => {
const { buffer } = objToPdu(input);
assert.ok(buffer); assert.ok(buffer);
sock.write(buffer);
},
};
}
const sock = net.connect({ port: smpp.port }); function bindOf(interfaceVersion: number, seqNr = 1): PduObjectInput {
const response = await once<Buffer>(resolve => { return {
sock.on('connect', () => { sock.write(buffer); }); cmdName: 'bind_transceiver',
sock.once('data', resolve); params: { interface_version: interfaceVersion, password: 'pass', system_id: 'user' },
}); seqNr,
};
}
sock.destroy(); async function bindRaw(smpp: SmppServer, interfaceVersion: number): Promise<PduObject> {
const peer = rawPeer(smpp.port);
const { pduObj } = pduToObj(response); peer.write(bindOf(interfaceVersion));
assert.ok(pduObj); const response = await peer.next();
return pduObj; peer.close();
return response;
} }
describe('bind', () => { describe('bind', () => {
@@ -161,6 +208,41 @@ describe('bind', () => {
await smpp.close(); await smpp.close();
}); });
test('advertises the version the server is configured with', async () => {
const smpp = await startServer({ interfaceVersion: 0x50 });
const asThreeFour = await bindRaw(smpp, 0x34);
assert.equal(asThreeFour.tlvs.sc_interface_version?.tagValue, 0x50);
// The threshold for sending optional parameters is 3.4 whatever the server advertises.
const asThreeThree = await bindRaw(smpp, 0x33);
assert.deepEqual(asThreeThree.tlvs, {});
await smpp.close();
});
test('answers a bind with its own system_id, not the one the ESME sent', async () => {
const anonymous = await startServer();
const named = await startServer({ systemId: 'the-smsc' });
assert.equal((await bindRaw(anonymous, 0x34)).params.system_id, '');
assert.equal((await bindRaw(named, 0x34)).params.system_id, 'the-smsc');
await anonymous.close();
await named.close();
});
test('answers a refused bind with its own system_id too', async () => {
const smpp = await startServer({ authenticate: () => false, systemId: 'the-smsc' });
const refused = await bindRaw(smpp, 0x34);
assert.equal(refused.cmdStatus, 'ESME_RBINDFAIL');
assert.equal(refused.params.system_id, 'the-smsc');
await smpp.close();
});
test('sends no optional parameters to a peer declaring less than 3.4', async () => { test('sends no optional parameters to a peer declaring less than 3.4', async () => {
const smpp = await startServer(); const smpp = await startServer();
const bound = await bindRaw(smpp, 0x00); const bound = await bindRaw(smpp, 0x00);
@@ -170,6 +252,23 @@ describe('bind', () => {
await smpp.close(); await smpp.close();
}); });
test('answers a second bind with ESME_RALYBND and its own system_id', async () => {
const smpp = await startServer({ systemId: 'the-smsc' });
const peer = rawPeer(smpp.port);
peer.write(bindOf(0x34));
await peer.next();
peer.write(bindOf(0x34, 2));
const again = await peer.next();
assert.equal(again.cmdStatus, 'ESME_RALYBND');
assert.equal(again.params.system_id, 'the-smsc');
peer.close();
await smpp.close();
});
}); });
describe('sending', () => { describe('sending', () => {
@@ -302,8 +401,8 @@ describe('delivery reports', () => {
assert.ok(session); assert.ok(session);
const dlr = once<{ smsId: string; statusMsg: string }>(resolve => { const dlr = once<[{ smsId: string; statusMsg: string }, PduObject]>(resolve => {
session.on('dlr', resolve); session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); });
}); });
const [sms] = await Promise.all([ const [sms] = await Promise.all([
@@ -319,10 +418,12 @@ describe('delivery reports', () => {
assert.ok(sms.dlr); assert.ok(sms.dlr);
await sms.sendDlr(); await sms.sendDlr();
const report = await dlr; const [report, receipt] = await dlr;
assert.equal(report.smsId, 'dlr-id'); assert.equal(report.smsId, 'dlr-id');
assert.equal(report.statusMsg, 'DELIVERED'); assert.equal(report.statusMsg, 'DELIVERED');
assert.equal(receipt.tlvs.receipted_message_id?.tagValue, 'dlr-id');
assert.equal(receipt.tlvs.message_state?.tagValue, 2);
session.close(); session.close();
await smpp.close(); await smpp.close();
@@ -365,6 +466,46 @@ describe('delivery reports', () => {
session.close(); session.close();
await smpp.close(); await smpp.close();
}); });
test('sends a text-only receipt to a peer that declared less than 3.4', async () => {
const smpp = await startServer();
const incoming = once<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const peer = rawPeer(smpp.port);
peer.write(bindOf(0x33));
await peer.next();
peer.write({
cmdName: 'submit_sm',
params: {
data_coding: 0,
destination_addr: '46709771337',
registered_delivery: 1,
short_message: 'hi',
sm_length: 2,
source_addr: '46701113311',
},
seqNr: 2,
});
const sms = await incoming;
await sms.sendResp();
await peer.next();
// A raw peer answers no deliver_sm, so this only settles once the session closes.
void sms.sendDlr();
const receipt = await peer.next();
assert.equal(receipt.cmdName, 'deliver_sm');
assert.deepEqual(receipt.tlvs, {});
assert.match(paramText(receipt.params.short_message), /stat:DELIVRD/);
peer.close();
await smpp.close();
});
}); });
describe('a session captured from Kannel', () => { describe('a session captured from Kannel', () => {
+6 -1
View File
@@ -5,7 +5,7 @@ rules there constrain every item below.
## Status ## Status
The rewrite is **feature complete and green**: 144 tests, lint and typecheck clean, verified on Node The rewrite is **feature complete and green**: 155 tests, lint and typecheck clean, verified on Node
18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0. 18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
```bash ```bash
@@ -74,6 +74,11 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
- [ ] **In-flight sends across a reconnect.** They currently fail with "Session closed before a - [ ] **In-flight sends across a reconnect.** They currently fail with "Session closed before a
response arrived" and the caller retries. Re-queueing them automatically would be friendlier response arrived" and the caller retries. Re-queueing them automatically would be friendlier
but risks duplicate delivery, so it needs a decision before it is built. but risks duplicate delivery, so it needs a decision before it is built.
- [ ] **`session.ts` is 459 lines.** The one seam left in it is a socket-to-PDU transport, which
would move the deliberately public `sock` field out of `Session` or turn it into a getter —
a public-surface change, so it waits for a decision.
- [ ] **`buildPdu` and `dlrFromPdu` carry a complexity of 22.** `eslint.config.js` holds them at
that ceiling rather than below the repo-wide 10, so neither can grow but neither shrinks.
- [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them - [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them
end to end. The interop suite is the natural place. end to end. The interop suite is the natural place.
- [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript - [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript