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