diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9ca9bf8..da98f90 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,6 +19,14 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - 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 test - run: npm publish --provenance --access public diff --git a/AGENTS.md b/AGENTS.md index 55f0337..3a30f9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,21 +35,33 @@ These are not preferences. Breaking one is a defect. ``` src/ - index.ts Public surface. Named exports only, no default export. - client.ts client() -> { err, session } - server.ts server() -> { err, server }, server owns the listener + close() - session.ts Session: framing, sequence numbers, the send window, events - sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr) - message.ts Encoding detection, splitting, bit counting, SMPP date formatting - pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning - result.ts Result — the shape every fallible call returns + index.ts Public surface. Named exports only, no default export. + client.ts client() -> { err, session } + server.ts server() -> { err, server }, server owns the listener + close() + session.ts Session: dispatch, events, and the collaborators below + 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 + 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 — 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/ - commands.ts The 33 commands, their ids and ordered parameter lists - constants.ts consts + constsById (TON, NPI, ENCODING, MESSAGE_STATE, …) - encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution - errors.ts errors + errorsById (ESME_*) - tlvs.ts TLV definitions, tlvsById - types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays + commands.ts The 33 commands, their ids and ordered parameter lists + constants.ts consts + constsById, and the SMPP version constants + encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution + errors.ts errors + errorsById (ESME_*) + tlvs.ts TLV definitions, tlvsById + types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays ``` Dependency direction is one way: `defs` knows nothing above it, `pdu` uses `defs`, `session` uses @@ -142,6 +154,12 @@ exactly 140. ## 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 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 diff --git a/README.md b/README.md index fc99c1f..4cc2cf2 100644 --- a/README.md +++ b/README.md @@ -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. | | `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. | | `idleTimeout` | `40000` | Drop a peer that has been silent this long. | | `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 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. +- 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. - 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, diff --git a/eslint.config.js b/eslint.config.js index b1e44ec..0216ded 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,29 @@ export default tseslint.config( '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. files: ['src/defs/encodings.ts'], diff --git a/src/client.ts b/src/client.ts index f74b88f..9f951c1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,6 +5,7 @@ import type { Socket } from 'node:net'; import { Session } from './session.ts'; import { connect as netConnect } from 'node:net'; import { connect as tlsConnect } from 'node:tls'; +import { defaultInterfaceVersion } from './defs/constants.ts'; import { silentLog } from './log.ts'; export type BindType = 'receiver' | 'transceiver' | 'transmitter'; @@ -33,8 +34,7 @@ const defaults = { bindType: 'transceiver', enquireLinkInterval: 20_000, host: 'localhost', - /** SMPP 3.4. The 3.4 spec reserves every value above it, so 0x50 is undefined to a 3.4 SMSC. */ - interfaceVersion: 0x34, + interfaceVersion: defaultInterfaceVersion, password: 'pass', port: 2775, username: 'user', @@ -45,9 +45,12 @@ const defaults = { * handshake, so those connections were not encrypted at all. */ function openSocket(options: ClientOptions): Promise> { + const host = options.host ?? defaults.host; + const port = options.port ?? defaults.port; + const secure = options.tls !== undefined && options.tls !== false; + const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; + return new Promise(resolve => { - const host = options.host ?? defaults.host; - const port = options.port ?? defaults.port; const signal = options.signal; if (signal?.aborted === true) { @@ -56,13 +59,7 @@ function openSocket(options: ClientOptions): Promise> { return; } - const sock = options.tls === undefined || options.tls === false - ? netConnect({ host, port }) - : tlsConnect({ - host, - port, - ...(typeof options.tls === 'object' ? options.tls : {}), - }); + const sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port }); const settle = (result: Result<{ sock: Socket }>): void => { sock.removeListener('error', onError); @@ -81,26 +78,30 @@ function openSocket(options: ClientOptions): Promise> { sock.once('error', onError); signal?.addEventListener('abort', onAbort, { once: true }); - sock.once(options.tls === undefined || options.tls === false ? 'connect' : 'secureConnect', () => { + sock.once(secure ? 'secureConnect' : 'connect', () => { settle({ sock }); }); }); } +function bindParams(options: ClientOptions, systemId: string) { + return { + address_range: options.addressRange ?? '', + addr_npi: options.addrNpi ?? 0, + addr_ton: options.addrTon ?? 0, + interface_version: options.interfaceVersion ?? defaults.interfaceVersion, + password: options.password ?? defaults.password, + system_id: systemId, + system_type: options.systemType ?? '', + }; +} + async function bind(session: Session, options: ClientOptions): Promise { const bindType = options.bindType ?? defaults.bindType; const systemId = options.username ?? defaults.username; const sent = await session.send({ cmdName: `bind_${bindType}`, - params: { - address_range: options.addressRange ?? '', - addr_npi: options.addrNpi ?? 0, - addr_ton: options.addrTon ?? 0, - interface_version: options.interfaceVersion ?? defaults.interfaceVersion, - password: options.password ?? defaults.password, - system_id: systemId, - system_type: options.systemType ?? '', - }, + params: bindParams(options, systemId), ...(options.signal ? { signal: options.signal } : {}), }); diff --git a/src/defs/constants.ts b/src/defs/constants.ts index 5b7f2f6..8980057 100644 --- a/src/defs/constants.ts +++ b/src/defs/constants.ts @@ -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 = { BROADCAST_AREA_FORMAT: { ALIAS: 0x00, diff --git a/src/defs/tlvs.ts b/src/defs/tlvs.ts index 39670a3..10c52e6 100644 --- a/src/defs/tlvs.ts +++ b/src/defs/tlvs.ts @@ -8,96 +8,92 @@ export type TlvDefinition = { 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 = ( + definitions: T, +): T => definitions; // Ordered by tag id, mirroring the SMPP 5.0 TLV table. -const specs = { - dest_addr_subunit: { id: 0x0005, type: tlv.int8 }, - dest_network_type: { id: 0x0006, type: tlv.int8 }, - dest_bearer_type: { id: 0x0007, type: tlv.int8 }, - dest_telematics_id: { id: 0x0008, type: tlv.int16 }, - source_addr_subunit: { id: 0x000D, type: tlv.int8 }, - source_network_type: { id: 0x000E, type: tlv.int8 }, - source_bearer_type: { id: 0x000F, type: tlv.int8 }, - source_telematics_id: { id: 0x0010, type: tlv.int16 }, - qos_time_to_live: { id: 0x0017, type: tlv.int32 }, - payload_type: { id: 0x0019, type: tlv.int8 }, - additional_status_info_text: { id: 0x001D, type: tlv.cstring }, - receipted_message_id: { id: 0x001E, type: tlv.cstring }, - ms_msg_wait_facilities: { id: 0x0030, type: tlv.int8 }, - privacy_indicator: { id: 0x0201, type: tlv.int8 }, - source_subaddress: { id: 0x0202, type: tlv.buffer }, - dest_subaddress: { id: 0x0203, type: tlv.buffer }, - user_message_reference: { id: 0x0204, type: tlv.int16 }, - user_response_code: { id: 0x0205, type: tlv.int8 }, - source_port: { id: 0x020A, type: tlv.int16 }, - dest_port: { id: 0x020B, type: tlv.int16 }, - sar_msg_ref_num: { id: 0x020C, type: tlv.int16 }, - language_indicator: { id: 0x020D, type: tlv.int8 }, - sar_total_segments: { id: 0x020E, type: tlv.int8 }, - sar_segment_seqnum: { id: 0x020F, type: tlv.int8 }, - sc_interface_version: { id: 0x0210, type: tlv.int8 }, - callback_num_pres_ind: { id: 0x0302, multiple: true, type: tlv.int8 }, - callback_num_atag: { id: 0x0303, multiple: true, type: tlv.buffer }, - number_of_messages: { id: 0x0304, type: tlv.int8 }, - callback_num: { id: 0x0381, multiple: true, type: tlv.buffer }, - dpf_result: { id: 0x0420, type: tlv.int8 }, - set_dpf: { id: 0x0421, type: tlv.int8 }, - ms_availability_status: { id: 0x0422, type: tlv.int8 }, - network_error_code: { id: 0x0423, type: tlv.buffer }, - message_payload: { id: 0x0424, type: tlv.buffer }, - delivery_failure_reason: { id: 0x0425, type: tlv.int8 }, - more_messages_to_send: { id: 0x0426, type: tlv.int8 }, - message_state: { id: 0x0427, type: tlv.int8 }, - congestion_state: { id: 0x0428, type: tlv.int8 }, - ussd_service_op: { id: 0x0501, type: tlv.int8 }, - broadcast_channel_indicator: { id: 0x0600, type: tlv.int8 }, - broadcast_content_type: { id: 0x0601, type: tlv.buffer }, - broadcast_content_type_info: { id: 0x0602, type: tlv.string }, - broadcast_message_class: { id: 0x0603, type: tlv.int8 }, - broadcast_rep_num: { id: 0x0604, type: tlv.int16 }, - broadcast_frequency_interval: { id: 0x0605, type: tlv.buffer }, - broadcast_area_identifier: { id: 0x0606, multiple: true, type: tlv.buffer }, - broadcast_error_status: { id: 0x0607, multiple: true, type: tlv.int32 }, - broadcast_area_success: { id: 0x0608, type: tlv.int8 }, - broadcast_end_time: { id: 0x0609, type: tlv.string }, - broadcast_service_group: { id: 0x060A, type: tlv.string }, - billing_identification: { id: 0x060B, type: tlv.buffer }, - source_network_id: { id: 0x060D, type: tlv.cstring }, - dest_network_id: { id: 0x060E, type: tlv.cstring }, - source_node_id: { id: 0x060F, type: tlv.string }, - dest_node_id: { id: 0x0610, type: tlv.string }, - dest_addr_np_resolution: { id: 0x0611, type: tlv.int8 }, - dest_addr_np_information: { id: 0x0612, type: tlv.string }, - dest_addr_np_country: { id: 0x0613, type: tlv.int32 }, - display_time: { id: 0x1201, type: tlv.int8 }, - sms_signal: { id: 0x1203, type: tlv.int16 }, - ms_validity: { id: 0x1204, type: tlv.buffer }, - alert_on_message_delivery: { id: 0x130C, type: tlv.int8 }, - its_reply_type: { id: 0x1380, type: tlv.int8 }, - its_session_info: { id: 0x1383, type: tlv.buffer }, -} satisfies Record; +const specs = tlvSpecs({ + dest_addr_subunit: { id: 0x0005, tag: 'dest_addr_subunit', type: tlv.int8 }, + dest_network_type: { id: 0x0006, tag: 'dest_network_type', type: tlv.int8 }, + dest_bearer_type: { id: 0x0007, tag: 'dest_bearer_type', type: tlv.int8 }, + dest_telematics_id: { id: 0x0008, tag: 'dest_telematics_id', type: tlv.int16 }, + source_addr_subunit: { id: 0x000D, tag: 'source_addr_subunit', type: tlv.int8 }, + source_network_type: { id: 0x000E, tag: 'source_network_type', type: tlv.int8 }, + source_bearer_type: { id: 0x000F, tag: 'source_bearer_type', type: tlv.int8 }, + source_telematics_id: { id: 0x0010, tag: 'source_telematics_id', type: tlv.int16 }, + qos_time_to_live: { id: 0x0017, tag: 'qos_time_to_live', type: tlv.int32 }, + payload_type: { id: 0x0019, tag: 'payload_type', type: tlv.int8 }, + additional_status_info_text: { id: 0x001D, tag: 'additional_status_info_text', type: tlv.cstring }, + receipted_message_id: { id: 0x001E, tag: 'receipted_message_id', type: tlv.cstring }, + ms_msg_wait_facilities: { id: 0x0030, tag: 'ms_msg_wait_facilities', type: tlv.int8 }, + privacy_indicator: { id: 0x0201, tag: 'privacy_indicator', type: tlv.int8 }, + source_subaddress: { id: 0x0202, tag: 'source_subaddress', type: tlv.buffer }, + dest_subaddress: { id: 0x0203, tag: 'dest_subaddress', type: tlv.buffer }, + user_message_reference: { id: 0x0204, tag: 'user_message_reference', type: tlv.int16 }, + user_response_code: { id: 0x0205, tag: 'user_response_code', type: tlv.int8 }, + source_port: { id: 0x020A, tag: 'source_port', type: tlv.int16 }, + dest_port: { id: 0x020B, tag: 'dest_port', type: tlv.int16 }, + sar_msg_ref_num: { id: 0x020C, tag: 'sar_msg_ref_num', type: tlv.int16 }, + language_indicator: { id: 0x020D, tag: 'language_indicator', type: tlv.int8 }, + sar_total_segments: { id: 0x020E, tag: 'sar_total_segments', type: tlv.int8 }, + sar_segment_seqnum: { id: 0x020F, tag: 'sar_segment_seqnum', type: tlv.int8 }, + sc_interface_version: { id: 0x0210, tag: 'sc_interface_version', 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, tag: 'callback_num_atag', type: tlv.buffer }, + number_of_messages: { id: 0x0304, tag: 'number_of_messages', type: tlv.int8 }, + callback_num: { id: 0x0381, multiple: true, tag: 'callback_num', type: tlv.buffer }, + dpf_result: { id: 0x0420, tag: 'dpf_result', type: tlv.int8 }, + set_dpf: { id: 0x0421, tag: 'set_dpf', type: tlv.int8 }, + ms_availability_status: { id: 0x0422, tag: 'ms_availability_status', type: tlv.int8 }, + network_error_code: { id: 0x0423, tag: 'network_error_code', type: tlv.buffer }, + message_payload: { id: 0x0424, tag: 'message_payload', type: tlv.buffer }, + delivery_failure_reason: { id: 0x0425, tag: 'delivery_failure_reason', type: tlv.int8 }, + more_messages_to_send: { id: 0x0426, tag: 'more_messages_to_send', type: tlv.int8 }, + message_state: { id: 0x0427, tag: 'message_state', type: tlv.int8 }, + congestion_state: { id: 0x0428, tag: 'congestion_state', type: tlv.int8 }, + ussd_service_op: { id: 0x0501, tag: 'ussd_service_op', type: tlv.int8 }, + broadcast_channel_indicator: { id: 0x0600, tag: 'broadcast_channel_indicator', type: tlv.int8 }, + broadcast_content_type: { id: 0x0601, tag: 'broadcast_content_type', type: tlv.buffer }, + broadcast_content_type_info: { id: 0x0602, tag: 'broadcast_content_type_info', type: tlv.string }, + broadcast_message_class: { id: 0x0603, tag: 'broadcast_message_class', type: tlv.int8 }, + broadcast_rep_num: { id: 0x0604, tag: 'broadcast_rep_num', type: tlv.int16 }, + broadcast_frequency_interval: { id: 0x0605, tag: 'broadcast_frequency_interval', type: tlv.buffer }, + broadcast_area_identifier: { id: 0x0606, multiple: true, tag: 'broadcast_area_identifier', type: tlv.buffer }, + broadcast_error_status: { id: 0x0607, multiple: true, tag: 'broadcast_error_status', type: tlv.int32 }, + broadcast_area_success: { id: 0x0608, tag: 'broadcast_area_success', type: tlv.int8 }, + broadcast_end_time: { id: 0x0609, tag: 'broadcast_end_time', type: tlv.string }, + broadcast_service_group: { id: 0x060A, tag: 'broadcast_service_group', type: tlv.string }, + billing_identification: { id: 0x060B, tag: 'billing_identification', type: tlv.buffer }, + source_network_id: { id: 0x060D, tag: 'source_network_id', type: tlv.cstring }, + dest_network_id: { id: 0x060E, tag: 'dest_network_id', type: tlv.cstring }, + source_node_id: { id: 0x060F, tag: 'source_node_id', type: tlv.string }, + dest_node_id: { id: 0x0610, tag: 'dest_node_id', type: tlv.string }, + dest_addr_np_resolution: { id: 0x0611, tag: 'dest_addr_np_resolution', type: tlv.int8 }, + dest_addr_np_information: { id: 0x0612, tag: 'dest_addr_np_information', type: tlv.string }, + dest_addr_np_country: { id: 0x0613, tag: 'dest_addr_np_country', type: tlv.int32 }, + display_time: { id: 0x1201, tag: 'display_time', type: tlv.int8 }, + sms_signal: { id: 0x1203, tag: 'sms_signal', type: tlv.int16 }, + ms_validity: { id: 0x1204, tag: 'ms_validity', type: tlv.buffer }, + alert_on_message_delivery: { id: 0x130C, tag: 'alert_on_message_delivery', type: tlv.int8 }, + its_reply_type: { id: 0x1380, tag: 'its_reply_type', type: tlv.int8 }, + its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer }, +}); export type TlvName = keyof typeof specs; -export const tlvs: Record = {}; -export const tlvsById: Record = {}; - -for (const [tag, spec] of Object.entries(specs)) { - const definition: TlvDefinition = { ...spec, tag }; - - tlvs[tag] = definition; - tlvsById[spec.id] = definition; -} - -// Alternate spellings that resolve to the same tag; the definition keeps its canonical name. -const aliases: Record = { - alert_on_msg_delivery: 'alert_on_message_delivery', - failed_broadcast_area_identifier: 'broadcast_area_identifier', +export const tlvs: Record & Record = { + ...specs, + // Alternate spellings; the definition behind each keeps its canonical name. + alert_on_msg_delivery: specs.alert_on_message_delivery, + failed_broadcast_area_identifier: specs.broadcast_area_identifier, }; -for (const [alias, target] of Object.entries(aliases)) { - tlvs[alias] = { ...specs[target], tag: target }; +export const tlvsById: Record = {}; + +for (const definition of Object.values(specs)) { + tlvsById[definition.id] = definition; } /** Fallback for tags this table does not know: keep the raw octets. */ diff --git a/src/dlr-merger.ts b/src/dlr-merger.ts new file mode 100644 index 0000000..2ed09ab --- /dev/null +++ b/src/dlr-merger.ts @@ -0,0 +1,64 @@ +import type { Dlr } from './dlr.ts'; + +export type MessageDlr = Dlr & { segments: Dlr[] }; + +type Group = { + expected: number; + parts: Map; +}; + +const numbered = /^(.*)-(\d+)$/; + +/** + * Merges the per-segment receipts of a multipart message into one report, but only when the peer + * numbered its ids `-` 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(); + + /** 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(); + + 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 }; + } +} diff --git a/src/link-timers.ts b/src/link-timers.ts new file mode 100644 index 0000000..0fed181 --- /dev/null +++ b/src/link-timers.ts @@ -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; + } +} diff --git a/src/pdu.ts b/src/pdu.ts index 29e49da..1ead29a 100644 --- a/src/pdu.ts +++ b/src/pdu.ts @@ -8,7 +8,7 @@ import { consts } from './defs/constants.ts'; import { decodeMessage, encodeMessage } from './message.ts'; import { detect, encodingByDataCoding } from './defs/encodings.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. */ export const maxSeqNr = 2147483646; @@ -17,8 +17,8 @@ export const maxSeqNr = 2147483646; export const maxPduLength = 1024 * 1024; export type TlvInput = { - tagId: number; - tagName?: string | undefined; + /** Resolved from the record key; pass it for a tag the TLV table does not define. */ + tagId?: number | undefined; tagValue: ParamValue; }; @@ -65,6 +65,20 @@ function numberOr(value: ParamValue | undefined, fallback: number): number { 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( cmdName: CommandName, cmdStatus: ErrorName, @@ -126,16 +140,24 @@ function buildPdu( } 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); if (sized.err) { 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); - chunk.writeUInt16BE(tlv.tagId, 0); + chunk.writeUInt16BE(tag.tagId, 0); chunk.writeUInt16BE(sized.size, 2); const written = type.write(tlv.tagValue, chunk, 4); @@ -192,7 +214,7 @@ function parseTlvs( tlvs[definition?.tag ?? tagId.toString()] = { tagId, tagName: definition?.tag, - tagValue: Buffer.isBuffer(read.value) ? read.value.toString('hex') : read.value, + tagValue: read.value, }; offset += 4 + tagLength; diff --git a/src/pending-requests.ts b/src/pending-requests.ts new file mode 100644 index 0000000..9947198 --- /dev/null +++ b/src/pending-requests.ts @@ -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(); + 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> { + 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; + } +} diff --git a/src/reassembly.ts b/src/reassembly.ts new file mode 100644 index 0000000..c472591 --- /dev/null +++ b/src/reassembly.ts @@ -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; + 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(); + 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; + } +} diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts new file mode 100644 index 0000000..8d63c1c --- /dev/null +++ b/src/reconnect-loop.ts @@ -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>; + 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; +}; + +/** 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 { + 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; + } +} diff --git a/src/send-sms.ts b/src/send-sms.ts new file mode 100644 index 0000000..bb1a6d9 --- /dev/null +++ b/src/send-sms.ts @@ -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>; +}; + +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 { + const params: Record = { + 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 { + 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 }; +} diff --git a/src/send-window.ts b/src/send-window.ts new file mode 100644 index 0000000..6f2dea4 --- /dev/null +++ b/src/send-window.ts @@ -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 { + if (this.inFlight < this.limit) { + this.inFlight++; + + return Promise.resolve(); + } + + return new Promise(resolve => this.waiting.push(resolve)); + } + + release(): void { + const next = this.waiting.shift(); + + if (next) { + next(); + + return; + } + + this.inFlight--; + } +} diff --git a/src/server.ts b/src/server.ts index 3ad369f..7b5cf7e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,12 +4,12 @@ import type { Result } from './result.ts'; import type { Server as NetServer, Socket } from 'node:net'; import type { Server as TlsServer, TlsOptions } from 'node:tls'; 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 createTlsServer } from 'node:tls'; +import { defaultInterfaceVersion } from './defs/constants.ts'; import { paramText } from './defs/types.ts'; import { silentLog } from './log.ts'; -import { tlvs } from './defs/tlvs.ts'; export type AuthenticateResult = { userData?: unknown } | boolean; @@ -24,6 +24,7 @@ export type ServerOptions = { authenticate?: (input: AuthenticateInput) => Promise | AuthenticateResult; host?: string; idleTimeout?: number; + interfaceVersion?: number; log?: LogInt; maxOutstanding?: number; maxReassembly?: number; @@ -31,6 +32,7 @@ export type ServerOptions = { reassemblyTimeout?: number; responseTimeout?: number; signal?: AbortSignal; + systemId?: string; tls?: TlsOptions | boolean; }; @@ -41,12 +43,11 @@ export type ServerEvents = { const defaults = { idleTimeout: 40_000, + interfaceVersion: defaultInterfaceVersion, 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. */ export class SmppServer extends EventEmitter { readonly sessions = new Set(); @@ -102,25 +103,46 @@ async function authenticate( return true; } -/** - * A peer that declared below 0x34 must not be sent optional parameters at all; above it, an ESME - * reads a missing sc_interface_version as this SMSC having none. - */ -function bindRespTlvs(pduObj: PduObject): Record | undefined { - const declared = pduObj.params.interface_version; - const definition = tlvs.sc_interface_version; - - if (!definition || typeof declared !== 'number' || declared < scInterfaceVersion) return undefined; +/** An ESME reads a missing sc_interface_version as this SMSC having none. */ +function bindRespTlvs(session: Session, options: ServerOptions): Record | undefined { + if (!session.acceptsOptionalParams()) return undefined; return { - sc_interface_version: { - tagId: definition.id, - tagName: definition.tag, - tagValue: scInterfaceVersion, - }, + sc_interface_version: { tagValue: options.interfaceVersion ?? defaults.interfaceVersion }, }; } +async function acceptBind( + session: Session, + pduObj: PduObject, + options: ServerOptions, + identity: Record, +): Promise { + 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 { + 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 * session leaves the PDU alone. @@ -130,27 +152,18 @@ async function onRequest( pduObj: PduObject, options: ServerOptions, ): Promise { - const log = options.log ?? silentLog; - if (session.loggedIn || pduObj.cmdName === 'unbind') return false; if (!bindCommands.includes(pduObj.cmdName)) { + const log = options.log ?? silentLog; + log.debug('server - command before bind', { cmdName: pduObj.cmdName }); await session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); return true; } - if (!await authenticate(session, pduObj, options)) { - log.info('server - bind refused', { systemId: paramText(pduObj.params.system_id) }); - await session.sendReturn(pduObj, 'ESME_RBINDFAIL'); - - return true; - } - - session.loggedIn = true; - await session.sendReturn(pduObj, 'ESME_ROK', {}, bindRespTlvs(pduObj)); - log.verbose('server - bound', { systemId: paramText(pduObj.params.system_id) }); + await onBind(session, pduObj, options); return true; } @@ -166,6 +179,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): reassemblyTimeout: options.reassemblyTimeout, responseTimeout: options.responseTimeout, sock, + systemId: options.systemId ?? defaults.systemId, }); server.sessions.add(session); @@ -190,28 +204,58 @@ function createSecureListener(tlsOptions: TlsOptions, log: LogInt): TlsServer { return listener; } +function createListener( + options: ServerOptions, + log: LogInt, + port: number, +): Result<{ listener: NetServer | TlsServer; useTls: boolean }> { + const useTls = options.tls !== undefined && options.tls !== false; + const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; + + if (useTls && !tlsOptions) { + log.warn('server - tls without a certificate', { port }); + + return { err: new Error('Listening over TLS needs tls: { cert, key }') }; + } + + return { + listener: tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(), + useTls, + }; +} + +function onListening(listener: NetServer, smpp: SmppServer, options: ServerOptions): void { + const log = options.log ?? silentLog; + + // Past startup, a listener error is a runtime event, not a failed start. + listener.on('error', (err: Error) => { + log.warn('server - error', { message: err.message }); + smpp.emit('serverError', err); + }); + + log.info('server - listening', { host: options.host ?? '*', port: smpp.port }); + + if (options.signal) { + options.signal.addEventListener('abort', () => { void smpp.close(); }, { once: true }); + } +} + /** Starts listening for SMPP connections. Resolves once the socket is bound. */ export function server(options: ServerOptions = {}): Promise> { + 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 log = options.log ?? silentLog; - const port = options.port ?? defaults.port; - const useTls = options.tls !== undefined && options.tls !== false; - const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; - - if (useTls && !tlsOptions) { - log.warn('server - tls without a certificate', { port }); - resolve({ err: new Error('Listening over TLS needs tls: { cert, key }') }); - - return; - } - - const listener = tlsOptions ? createSecureListener(tlsOptions, log) : createNetServer(); - const smpp = new SmppServer(listener); - - listener.on(useTls ? 'secureConnection' : 'connection', (sock: Socket) => { - onConnection(sock, options, smpp); - }); - const onStartupError = (err: Error): void => { listener.removeListener('error', onStartupError); log.warn('server - could not listen', { message: err.message, port }); @@ -222,19 +266,7 @@ export function server(options: ServerOptions = {}): Promise { listener.removeListener('error', onStartupError); - - // Past startup, a listener error is a runtime event, not a failed start. - listener.on('error', (err: Error) => { - log.warn('server - error', { message: err.message }); - smpp.emit('serverError', err); - }); - - log.info('server - listening', { host: options.host ?? '*', port: smpp.port }); - - if (options.signal) { - options.signal.addEventListener('abort', () => { void smpp.close(); }, { once: true }); - } - + onListening(listener, smpp, options); resolve({ server: smpp }); }); }); diff --git a/src/session.ts b/src/session.ts index d596ef7..83837e1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,25 +1,31 @@ import type { Dlr } from './dlr.ts'; -import type { EncodingName } from './defs/encodings.ts'; import type { ErrorName } from './defs/errors.ts'; import type { LogInt } from '@larvit/log'; +import type { MessageDlr } from './dlr-merger.ts'; import type { ParamValue } from './defs/types.ts'; import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts'; import type { Result, VoidResult } from './result.ts'; +import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; import type { Sms } from './sms.ts'; import type { Socket } from 'node:net'; +import { DlrMerger } from './dlr-merger.ts'; import { EventEmitter } from 'node:events'; +import { LinkTimers } from './link-timers.ts'; import { PduFramer } from './pdu-framer.ts'; +import { PendingRequests } from './pending-requests.ts'; +import { ReconnectLoop } from './reconnect-loop.ts'; +import { Reassembler, decodeSegments } from './reassembly.ts'; +import { SendWindow } from './send-window.ts'; import { concatInfo } from './udh.ts'; -import { consts } from './defs/constants.ts'; +import { consts, optionalParamsMinVersion } from './defs/constants.ts'; import { createSms } from './sms.ts'; -import { decodeMessage, smppTime, splitMessage } from './message.ts'; -import { detect } from './defs/encodings.ts'; import { dlrFromPdu } from './dlr.ts'; -import { isResp, maxSeqNr, objToPdu, pduReturn, pduToObj } from './pdu.ts'; +import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts'; import { paramText } from './defs/types.ts'; import { silentLog } from './log.ts'; +import { submitSms } from './send-sms.ts'; -export type MessageDlr = Dlr & { segments: Dlr[] }; +export type { MessageDlr, SendSmsOptions }; export type SessionEvents = { close: []; @@ -46,21 +52,6 @@ export type ReconnectOptions = { onConnected: (session: Session) => Promise; }; -export type SendSmsOptions = { - dlr?: boolean; - destinationAddrNpi?: number; - destinationAddrTon?: number; - encoding?: EncodingName; - flash?: boolean; - from: string; - message: string; - scheduleDeliveryTime?: Date | number | string; - sourceAddrNpi?: number; - sourceAddrTon?: number; - to: string; - validityPeriod?: Date | number | string; -}; - export type SessionOptions = { enquireLinkInterval?: number | undefined; idleTimeout?: number | undefined; @@ -77,16 +68,8 @@ export type SessionOptions = { reconnect?: ReconnectOptions | undefined; responseTimeout?: number | undefined; sock: Socket; -}; - -type Pending = { - settle: (result: Result<{ pduObj: PduObject }>) => void; -}; - -type Reassembly = { - parts: Map; - timer: NodeJS.Timeout; - total: number; + /** This end's own identity, answered to the peer in place of the one it sent. */ + systemId?: string | undefined; }; const defaults = { @@ -96,19 +79,14 @@ const defaults = { minDelay: 1000, reassemblyTimeout: 300_000, responseTimeout: 30_000, + systemId: '', }; -/** 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 const bindCommands: readonly string[] = [ + 'bind_receiver', + 'bind_transceiver', + 'bind_transmitter', +]; export class Session extends EventEmitter { /** Replaced on reconnect, so hold the session rather than this. */ @@ -116,50 +94,52 @@ export class Session extends EventEmitter { readonly log: LogInt; loggedIn = false; + /** The interface_version the peer declared when binding; undefined until a bind is accepted. */ + peerInterfaceVersion: number | undefined = undefined; userData: unknown = undefined; - private framer = new PduFramer(); + private readonly dlrMerger = new DlrMerger(); private readonly options: SessionOptions; - private readonly pending = new Map(); - private readonly reassembly = new Map(); - private readonly segmentDlrs = new Map }>(); - private readonly waiting: (() => void)[] = []; + private readonly pending: PendingRequests; + private readonly reassembler: Reassembler; + private readonly reconnectLoop: ReconnectLoop | undefined; + private readonly timers: LinkTimers; + private readonly window: SendWindow; private closed = false; private concatReference = 0; - private enquireLinkTimer: NodeJS.Timeout | undefined; - private idleTimer: NodeJS.Timeout | undefined; - private inFlight = 0; - private ourSeqNr = 1; - private reconnectDelay: number; - private reconnectTimer: NodeJS.Timeout | undefined; - private stopped = false; + private framer = new PduFramer(); constructor(options: SessionOptions) { super(); - this.options = options; 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.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.resetTimers(); } - /** Wires a freshly opened socket into this session, replacing any previous one. */ - private attach(sock: Socket): void { - this.sock = sock; - this.framer = new PduFramer(); - this.closed = false; - - sock.on('data', chunk => { this.onData(chunk); }); - sock.on('close', () => { this.onClose(); }); - sock.on('error', err => { - this.log.warn('session - socket error', { message: err.message }); - this.emit('sessionError', err); - this.onClose(); - }); + /** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */ + acceptsOptionalParams(): boolean { + return this.peerInterfaceVersion === undefined + || this.peerInterfaceVersion >= optionalParamsMinVersion; } /** Sends a request and resolves with the peer's response. */ @@ -173,26 +153,12 @@ export class Session extends EventEmitter { if (this.closed) return { err: new Error('Session is closed') }; - await this.acquire(); + await this.window.acquire(); try { - const seqNr = this.nextSeqNr(); - const built = objToPdu({ ...input, seqNr }); - - if (built.err) return { err: built.err }; - - const response = this.awaitResponse(seqNr, options.signal); - const written = this.write(built.buffer); - - if (written.err) { - this.settle(seqNr, { err: written.err }); - - return { err: written.err }; - } - - return await response; + return await this.request(input, options); } finally { - this.release(); + this.window.release(); } } @@ -210,57 +176,16 @@ export class Session extends EventEmitter { return Promise.resolve(this.write(built.buffer)); } - async sendSms( - sms: SendSmsOptions, - options: SendOptions = {}, - ): Promise> { - const encoding = sms.encoding ?? detect(sms.message); - const segments = splitMessage(sms.message, { - encoding, + async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise { + const sent = await submitSms({ + log: this.log, reference: this.nextConcatReference(), - }); - const pduObjs: PduObject[] = []; - const smsIds: string[] = []; + send: input => this.send(input, options), + }, sms); - 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 - // segment before answering — this library's own server does — would otherwise deadlock. - const sent = await Promise.all(segments.map(segment => { - const params: Record = { - 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 }; + return sent; } /** Unbinds politely, then closes. */ @@ -274,71 +199,99 @@ export class Session extends EventEmitter { /** Closes for good. A session closed this way never reconnects. */ close(): void { - this.stopped = true; - - if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - - this.reconnectTimer = undefined; + this.reconnectLoop?.stop(); this.teardown(); } + private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined { + if (!reconnect) return undefined; + + return new ReconnectLoop({ + connect: reconnect.connect, + log: this.log, + maxDelay: reconnect.maxDelay ?? defaults.maxDelay, + minDelay: reconnect.minDelay ?? defaults.minDelay, + onConnected: sock => this.comeBackUp(sock, reconnect.onConnected), + }); + } + + private async comeBackUp( + sock: Socket, + bind: (session: Session) => Promise, + ): Promise { + 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> { + const seqNr = this.pending.nextSeqNr(); + const built = objToPdu({ ...input, seqNr }); + + if (built.err) return { err: built.err }; + + const response = this.pending.wait(seqNr, { + signal: options.signal, + timeout: this.options.responseTimeout ?? defaults.responseTimeout, + }); + const written = this.write(built.buffer); + + if (written.err) { + this.pending.settle(seqNr, { err: written.err }); + + return { err: written.err }; + } + + return response; + } + private teardown(): void { if (this.closed) return; this.closed = true; - this.clearTimers(); - - for (const [seqNr] of this.pending) { - this.settle(seqNr, { err: new Error('Session closed before a response arrived') }); - } - - for (const group of this.reassembly.values()) { - clearTimeout(group.timer); - } - - this.reassembly.clear(); + this.timers.clear(); + this.pending.settleAll(new Error('Session closed before a response arrived')); + this.reassembler.clear(); this.sock.destroy(); this.emit('close'); } - private nextSeqNr(): number { - const seqNr = this.ourSeqNr; - - this.ourSeqNr = this.ourSeqNr >= maxSeqNr ? 1 : this.ourSeqNr + 1; - - return seqNr; - } - private nextConcatReference(): number { this.concatReference = this.concatReference >= 255 ? 1 : this.concatReference + 1; return this.concatReference; } - private async acquire(): Promise { - const limit = this.options.maxOutstanding ?? defaults.maxOutstanding; - - if (this.inFlight < limit) { - this.inFlight++; - - return; - } - - return new Promise(resolve => this.waiting.push(resolve)); - } - - private release(): void { - const next = this.waiting.shift(); - - if (next) { - next(); - - return; - } - - this.inFlight--; - } - private write(pdu: Buffer): VoidResult { if (this.sock.destroyed) { return { err: new Error('Socket is closed') }; @@ -349,49 +302,6 @@ export class Session extends EventEmitter { return {}; } - private awaitResponse( - seqNr: number, - signal: AbortSignal | undefined, - ): Promise> { - return new Promise(resolve => { - const timeout = this.options.responseTimeout ?? defaults.responseTimeout; - let timer: NodeJS.Timeout | undefined; - - const onAbort = (): void => { - this.settle(seqNr, { err: new Error('Aborted before a response arrived') }); - }; - - const settle = (result: Result<{ pduObj: PduObject }>): void => { - if (timer) clearTimeout(timer); - signal?.removeEventListener('abort', onAbort); - this.pending.delete(seqNr); - resolve(result); - }; - - this.pending.set(seqNr, { settle }); - - if (signal?.aborted === true) { - settle({ err: new Error('Aborted before a response arrived') }); - - return; - } - - signal?.addEventListener('abort', onAbort, { once: true }); - - if (timeout > 0) { - timer = setTimeout(() => { - this.log.warn('session - no response before the timeout', { seqNr, timeout }); - this.settle(seqNr, { err: new Error(`No response to seqNr ${String(seqNr)}`) }); - }, timeout); - timer.unref(); - } - }); - } - - private settle(seqNr: number, result: Result<{ pduObj: PduObject }>): void { - this.pending.get(seqNr)?.settle(result); - } - private onData(chunk: Buffer): void { this.emit('data', chunk); this.resetTimers(); @@ -408,36 +318,37 @@ export class Session extends EventEmitter { } for (const pdu of framed.pdus) { - this.emit('incomingPdu', pdu); - - const parsed = pduToObj(pdu); - - if (parsed.err) { - this.log.warn('session - could not parse an incoming PDU, closing', { - message: parsed.err.message, - }); - this.emit('sessionError', parsed.err); - this.close(); - - return; - } - - this.dispatch(parsed.pduObj); + if (!this.receive(pdu)) return; } } + /** False means the PDU could not be read and the session has been closed. */ + private receive(pdu: Buffer): boolean { + this.emit('incomingPdu', pdu); + + const parsed = pduToObj(pdu); + + if (parsed.err) { + this.log.warn('session - could not parse an incoming PDU, closing', { + message: parsed.err.message, + }); + this.emit('sessionError', parsed.err); + this.close(); + + return false; + } + + this.dispatch(parsed.pduObj); + + return true; + } + private dispatch(pduObj: PduObject): void { if (isResp(pduObj)) { - const pending = this.pending.get(pduObj.seqNr); - - if (!pending) { + if (!this.pending.deliver(pduObj)) { this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr }); - - return; } - pending.settle({ pduObj }); - return; } @@ -450,27 +361,32 @@ export class Session extends EventEmitter { if (onRequest && await onRequest(this, pduObj)) return; - if (pduObj.cmdName === 'enquire_link') { - await this.sendReturn(pduObj); - - return; + switch (pduObj.cmdName) { + case 'deliver_sm': + await this.onDeliverSm(pduObj); + break; + case 'enquire_link': + await this.sendReturn(pduObj); + break; + case 'submit_sm': + this.onSubmitSm(pduObj); + break; + case 'unbind': + await this.sendReturn(pduObj); + this.close(); + break; + default: + await this.unhandled(pduObj); } + } - if (pduObj.cmdName === 'unbind') { - await this.sendReturn(pduObj); - this.close(); - - return; - } - - if (pduObj.cmdName === 'submit_sm') { - this.onSubmitSm(pduObj); - - return; - } - - if (pduObj.cmdName === 'deliver_sm') { - await this.onDeliverSm(pduObj); + private async unhandled(pduObj: PduObject): Promise { + if (bindCommands.includes(pduObj.cmdName)) { + this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName }); + // Explicit, or pduReturn's echo answers the peer with its own system_id instead of ours. + await this.sendReturn(pduObj, 'ESME_RALYBND', { + system_id: this.options.systemId ?? defaults.systemId, + }); return; } @@ -484,14 +400,7 @@ export class Session extends EventEmitter { const esmClass = pduObj.params.esm_class; const hasUdh = typeof esmClass === 'number' && (esmClass & consts.ESM_CLASS.UDH_INDICATOR) === consts.ESM_CLASS.UDH_INDICATOR; - - if (!hasUdh || !Buffer.isBuffer(message)) { - this.emitSms([pduObj]); - - return; - } - - const concat = concatInfo(message); + const concat = hasUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined; if (!concat) { this.emitSms([pduObj]); @@ -499,66 +408,9 @@ export class Session extends EventEmitter { return; } - this.collectSegment(pduObj, concat); - } + const whole = this.reassembler.collect(pduObj, concat); - private collectSegment( - pduObj: PduObject, - concat: { part: number; reference: number; total: number }, - ): void { - const key = [ - paramText(pduObj.params.source_addr), - paramText(pduObj.params.destination_addr), - String(concat.reference), - ].join('_'); - - let group = this.reassembly.get(key); - - if (!group) { - const limit = this.options.maxReassembly ?? defaults.maxReassembly; - - if (this.reassembly.size >= limit) { - const oldest = this.reassembly.keys().next(); - - if (!oldest.done) { - this.log.warn('session - reassembly buffer full, dropping the oldest message', { - limit, - }); - this.dropReassembly(oldest.value); - } - } - - const timer = setTimeout(() => { - this.log.info('session - incomplete message expired', { key, total: concat.total }); - this.dropReassembly(key); - }, this.options.reassemblyTimeout ?? defaults.reassemblyTimeout); - - timer.unref(); - group = { parts: new Map(), timer, total: concat.total }; - this.reassembly.set(key, group); - } - - group.parts.set(concat.part, pduObj); - - if (group.parts.size < group.total) return; - - clearTimeout(group.timer); - this.reassembly.delete(key); - - const ordered = [...group.parts.entries()] - .sort(([a], [b]) => a - b) - .map(([, part]) => part); - - this.emitSms(ordered); - } - - private dropReassembly(key: string): void { - const group = this.reassembly.get(key); - - if (!group) return; - - clearTimeout(group.timer); - this.reassembly.delete(key); + if (whole) this.emitSms(whole); } private emitSms(pduObjs: PduObject[]): void { @@ -566,25 +418,9 @@ export class Session extends EventEmitter { if (!first) return; - let message = ''; - - for (const pduObj of pduObjs) { - const part = pduObj.params.short_message; - const dataCoding = pduObj.params.data_coding; - const esmClass = pduObj.params.esm_class; - - message += Buffer.isBuffer(part) - ? decodeMessage( - part, - typeof dataCoding === 'number' ? dataCoding : 0, - typeof esmClass === 'number' ? esmClass : 0, - ).message - : paramText(part); - } - this.emit('sms', createSms({ from: paramText(first.params.source_addr), - message, + message: decodeSegments(pduObjs), pduObjs, session: this, to: paramText(first.params.destination_addr), @@ -602,158 +438,22 @@ export class Session extends EventEmitter { } this.emit('dlr', dlr, pduObj); - this.collectSegmentDlr(dlr); + + const merged = this.dlrMerger.collect(dlr); + + if (merged) this.emit('messageDlr', merged); + await this.sendReturn(pduObj); } - /** - * Registers a multipart message for merged reporting, but only when the peer numbered its ids - * `-` 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(); - - 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 { if (this.closed) return; - this.clearTimers(); - - const { enquireLinkInterval, idleTimeout } = this.options; - - if (enquireLinkInterval !== undefined && enquireLinkInterval > 0) { - this.enquireLinkTimer = setTimeout(() => { - void this.send({ cmdName: 'enquire_link' }); - }, enquireLinkInterval); - this.enquireLinkTimer.unref(); - } - - if (idleTimeout !== undefined && idleTimeout > 0) { - this.idleTimer = setTimeout(() => { - this.log.info('session - closing an idle peer', { idleTimeout }); - this.close(); - }, idleTimeout); - this.idleTimer.unref(); - } - } - - private clearTimers(): void { - if (this.enquireLinkTimer) clearTimeout(this.enquireLinkTimer); - if (this.idleTimer) clearTimeout(this.idleTimer); - - this.enquireLinkTimer = undefined; - this.idleTimer = undefined; + this.timers.reset(); } private onClose(): void { this.teardown(); - - 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 { - 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'); + this.reconnectLoop?.schedule(); } } diff --git a/src/sms.ts b/src/sms.ts index d477e57..e8b3ba3 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -1,6 +1,6 @@ import type { ErrorName } from './defs/errors.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 { Session } from './session.ts'; import { consts } from './defs/constants.ts'; @@ -80,44 +80,47 @@ async function sendResp(sms: Sms, status: ErrorName = 'ESME_ROK'): Promise result.err) ?? {}; } +/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ +function receiptText(sms: Sms, smsId: string, status: MessageState): string { + const delivered = status === 'DELIVERED'; + + return [ + `id:${smsId}`, + 'sub:001', + `dlvrd:${delivered ? '001' : '000'}`, + `submit date:${smppDate(sms.submitTime)}`, + `done date:${smppDate(new Date())}`, + `stat:${receiptCodes[status]}`, + `err:${delivered ? '000' : '001'}`, + 'text:', + ].join(' '); +} + +function receiptTlvs(smsId: string, status: MessageState): Record { + return { + message_state: { tagValue: consts.MESSAGE_STATE[status] }, + receipted_message_id: { tagValue: smsId }, + }; +} + async function sendDlr( sms: Sms, status: MessageState = 'DELIVERED', ): Promise> { - const statusId = consts.MESSAGE_STATE[status]; const total = sms.pduObjs.length; const pduObjs: PduObject[] = []; for (let index = 0; index < total; index++) { const smsId = segmentId(sms.smsId, index, total); - const delivered = status === 'DELIVERED'; - const message = [ - `id:${smsId}`, - 'sub:001', - `dlvrd:${delivered ? '001' : '000'}`, - `submit date:${smppDate(sms.submitTime)}`, - `done date:${smppDate(new Date())}`, - `stat:${receiptCodes[status]}`, - `err:${delivered ? '000' : '001'}`, - 'text:', - ].join(' '); - const sent = await sms.session.send({ cmdName: 'deliver_sm', params: { destination_addr: sms.from, esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, - short_message: message, + short_message: receiptText(sms, smsId, status), source_addr: sms.to, }, - tlvs: { - message_state: { tagId: 0x0427, tagName: 'message_state', tagValue: statusId }, - receipted_message_id: { - tagId: 0x001E, - tagName: 'receipted_message_id', - tagValue: smsId, - }, - }, + ...(sms.session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), }); if (sent.err) return { err: sent.err }; diff --git a/test/dlr.test.ts b/test/dlr.test.ts index b0fbc26..0bbadc6 100644 --- a/test/dlr.test.ts +++ b/test/dlr.test.ts @@ -61,8 +61,8 @@ describe('parseReceipt()', () => { describe('dlrFromPdu()', () => { test('prefers the TLVs when the peer sends them', () => { const dlr = dlrFromPdu(deliverSm(receiptText, { - message_state: { tagId: 0x0427, tagValue: 5 }, - receipted_message_id: { tagId: 0x001E, tagValue: 'from-the-tlv' }, + message_state: { tagValue: 5 }, + receipted_message_id: { tagValue: 'from-the-tlv' }, })); assert.ok(dlr); diff --git a/test/pdu.test.ts b/test/pdu.test.ts index d73df4d..f99810b 100644 --- a/test/pdu.test.ts +++ b/test/pdu.test.ts @@ -195,22 +195,90 @@ describe('TLVs', () => { }, seqNr: 393, tlvs: { - 5142: { tagId: 5142, tagName: 'Nils', tagValue: Buffer.from('blajfoo', 'ascii') }, - receipted_message_id: { - tagId: 0x001E, - tagName: 'receipted_message_id', - tagValue: '293f293', - }, + 5142: { tagId: 5142, tagValue: Buffer.from('blajfoo', 'ascii') }, + 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.equal(Buffer.from(typeof unknown === 'string' ? unknown : '', 'hex').toString('ascii'), 'blajfoo'); + assert.ok(unknown); + 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', () => { @@ -225,8 +293,8 @@ describe('TLVs', () => { }, seqNr: 323, tlvs: { - message_state: { tagId: 1063, tagName: 'message_state', tagValue: 2 }, - receipted_message_id: { tagId: 30, tagName: 'receipted_message_id', tagValue: 450 }, + message_state: { tagId: 1063, tagValue: 2 }, + receipted_message_id: { tagId: 30, tagValue: 450 }, }, })); diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index ca48f6f..7501161 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -2,11 +2,13 @@ import assert from 'node:assert/strict'; import net from 'node:net'; import test, { describe } from 'node:test'; import type { MessageDlr } from '../src/session.ts'; +import type { PduObject } from '../src/pdu.ts'; import type { Sms } from '../src/sms.ts'; import type { SmppServer } from '../src/server.ts'; +import { Reassembler } from '../src/reassembly.ts'; import { client } from '../src/client.ts'; -import { objToPdu } from '../src/pdu.ts'; import { server } from '../src/server.ts'; +import { silentLog } from '../src/log.ts'; async function startServer(options: Parameters[0] = {}): Promise { const { err, server: smpp } = await server({ ...options, port: 0 }); @@ -171,95 +173,77 @@ describe('reconnect', () => { }); describe('reassembly bounds', () => { - function segment(reference: number, part: number, total: number, seqNr: number): Buffer { - const body = Buffer.concat([ - Buffer.from([0x05, 0x00, 0x03, reference, total, part]), - Buffer.from('fragment'), - ]); - const { buffer } = objToPdu({ + function segment(reference: number, part: number, total: number): PduObject { + const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]); + + return { + cmdId: 0x00000004, + cmdLength: 0, cmdName: 'submit_sm', + cmdStatus: 'ESME_ROK', + cmdStatusId: 0, params: { data_coding: 0, destination_addr: '46709771337', esm_class: 0x40, - short_message: body, - sm_length: body.length, + short_message: Buffer.concat([udh, Buffer.from('fragment')]), source_addr: '46701113311', }, - seqNr, - }); - - assert.ok(buffer); - - return buffer; + seqNr: part, + tlvs: {}, + }; } - // 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 () => { - const smpp = await startServer({ maxReassembly: 2, reassemblyTimeout: 60_000 }); + function collect( + reassembler: Reassembler, + reference: number, + part: number, + total: number, + ): PduObject[] | undefined { + return reassembler.collect(segment(reference, part, total), { part, reference, total }); + } - let delivered = 0; + 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 }); - smpp.on('session', session => session.on('sms', () => { delivered++; })); + assert.equal(collect(reassembler, 4, 2, 3), undefined); + assert.equal(collect(reassembler, 4, 3, 3), undefined); - const sock = net.connect({ port: smpp.port }, () => { - sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex')); - }); + const whole = collect(reassembler, 4, 1, 3); - 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. - sock.write(segment(1, 2, 2, 13)); - }); - - await new Promise(resolve => setTimeout(resolve, 200)); - - assert.equal(delivered, 0, 'an evicted message must not be delivered'); - - sock.destroy(); - await smpp.close(); + assert.ok(whole); + assert.deepEqual(whole.map(pduObj => pduObj.seqNr), [1, 2, 3]); + assert.equal(reassembler.size, 0); }); - test('expires an incomplete message on its own timer', async () => { - const smpp = await startServer({ reassemblyTimeout: 60 }); + // 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', () => { + 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++; })); + // Completing the first one must not produce a message: it was evicted. + assert.equal(collect(reassembler, 1, 2, 2), undefined); + assert.equal(reassembler.size, 2); - const sock = net.connect({ port: smpp.port }, () => { - sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex')); - }); + reassembler.clear(); + }); - let bound = false; + 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 }); - sock.on('data', () => { - if (bound) return; + assert.equal(collect(reassembler, 9, 1, 2), undefined); - bound = true; - sock.write(segment(9, 1, 2, 20)); - }); - - await new Promise(resolve => setTimeout(resolve, 200)); + now = 61; // 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)); - - assert.equal(delivered, 0); - - sock.destroy(); - await smpp.close(); + reassembler.clear(); }); }); diff --git a/test/session.test.ts b/test/session.test.ts index 05a72a0..87e0cca 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -1,12 +1,14 @@ import assert from 'node:assert/strict'; import net from 'node:net'; 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 { Sms } from '../src/sms.ts'; import type { SmppServer } from '../src/server.ts'; +import { PduFramer } from '../src/pdu-framer.ts'; import { client } from '../src/client.ts'; import { isCommand, objToPdu, pduToObj } from '../src/pdu.ts'; +import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; async function startServer(options: Parameters[0] = {}): Promise { @@ -26,28 +28,73 @@ function once(register: (resolve: (value: T) => void) => void): Promise { return new Promise(resolve => { register(resolve); }); } -/** Binds off a raw socket, which is the only way to declare a version the client cannot. */ -async function bindRaw(smpp: SmppServer, interfaceVersion: number): Promise { - const { buffer } = objToPdu({ +type RawPeer = { + close: () => void; + /** The next PDU the server sends, queued so none is missed between reads. */ + next: () => Promise; + 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(resolve => waiting.push(resolve)); + }, + write: input => { + const { buffer } = objToPdu(input); + + assert.ok(buffer); + sock.write(buffer); + }, + }; +} + +function bindOf(interfaceVersion: number, seqNr = 1): PduObjectInput { + return { cmdName: 'bind_transceiver', params: { interface_version: interfaceVersion, password: 'pass', system_id: 'user' }, - }); + seqNr, + }; +} - assert.ok(buffer); +async function bindRaw(smpp: SmppServer, interfaceVersion: number): Promise { + const peer = rawPeer(smpp.port); - const sock = net.connect({ port: smpp.port }); - const response = await once(resolve => { - sock.on('connect', () => { sock.write(buffer); }); - sock.once('data', resolve); - }); + peer.write(bindOf(interfaceVersion)); - sock.destroy(); + const response = await peer.next(); - const { pduObj } = pduToObj(response); + peer.close(); - assert.ok(pduObj); - - return pduObj; + return response; } describe('bind', () => { @@ -161,6 +208,41 @@ describe('bind', () => { 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 () => { const smpp = await startServer(); const bound = await bindRaw(smpp, 0x00); @@ -170,6 +252,23 @@ describe('bind', () => { 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', () => { @@ -302,8 +401,8 @@ describe('delivery reports', () => { assert.ok(session); - const dlr = once<{ smsId: string; statusMsg: string }>(resolve => { - session.on('dlr', resolve); + const dlr = once<[{ smsId: string; statusMsg: string }, PduObject]>(resolve => { + session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); }); }); const [sms] = await Promise.all([ @@ -319,10 +418,12 @@ describe('delivery reports', () => { assert.ok(sms.dlr); await sms.sendDlr(); - const report = await dlr; + const [report, receipt] = await dlr; assert.equal(report.smsId, 'dlr-id'); 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(); await smpp.close(); @@ -365,6 +466,46 @@ describe('delivery reports', () => { session.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(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', () => { diff --git a/todo.md b/todo.md index 06517f0..5ad63d2 100644 --- a/todo.md +++ b/todo.md @@ -5,7 +5,7 @@ rules there constrain every item below. ## 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. ```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 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. +- [ ] **`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 end to end. The interop suite is the natural place. - [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript