Enforce bind direction, cap segments per message and pin the README examples

This commit is contained in:
2026-08-27 14:07:42 +02:00
parent 8d245c82c4
commit 366fa26b1c
14 changed files with 568 additions and 31 deletions
+8 -1
View File
@@ -55,7 +55,7 @@ src/
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
session-options.ts SessionOptions, ReconnectOptions and the session defaults
session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
udh.ts User data header: the concatenation fields of a long SMS
uuid.ts uuidv7() — the ids the library generates for messages
defs/
@@ -185,6 +185,13 @@ exactly 140.
`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.
- **Bind direction is enforced on the library's own senders and on everything incoming, not on
`send()`.** A receiver-bound ESME carries no `submit_sm` and a transmitter-bound one no
`deliver_sm`; `sendSms()` and `sendDlr()` refuse locally, and an arriving PDU is answered
`ESME_RINVBNDSTS`. `bindAllows()` is a predicate on the same footing as `acceptsOptionalParams()`,
so the deliberately public low-level `send()` stays a passthrough. Only those two commands are
policed, because they are the only ones the library sends and dispatches by direction.
- **The logger is a five-method contract this library declares, not a dependency.** `SmppLog` in
`log.ts` is what the code actually calls (`debug`, `error`, `info`, `verbose`, `warn`), so an
application can satisfy it with an object literal and `@larvit/smpp` ships with no runtime
+28 -1
View File
@@ -92,16 +92,24 @@ Every one is optional.
```javascript
await session.sendSms({
dlr: true, // ask for a delivery report
destinationAddrNpi: 0, // override the numbering plan of the recipient
destinationAddrTon: 1,
encoding: 'UCS2', // override the automatic choice
flash: false,
from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1
maxSegments: 10, // refuse a longer message instead of sending it
message: 'Hello world',
scheduleDeliveryTime: new Date(Date.now() + 3600_000),
sourceAddrNpi: 0, // override the numbering plan of the sender
sourceAddrTon: 5,
to: '46709771337',
validityPeriod: 3600, // seconds, or a Date
}, { signal }); // optional per-call AbortSignal
```
`sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address and 1 for a
numeric one; the NPI fields default to 0. Set them for an operator that requires something else.
Messages too long for one SMS are split automatically and sent as a concatenated message. You get
one id per segment:
@@ -113,7 +121,8 @@ const { err, pduObjs, smsIds } = await session.sendSms({ from, message, to });
segment goes on the wire together, `pduObjs` and `smsIds` then hold what the SMSC did accept — enough
to reconcile against a later receipt, not enough to resend the rest, so treat a partial failure as a
failed message. A message needing more than 255 segments is refused before anything is sent, since
the concatenation header numbers segments in a single octet.
the concatenation header numbers segments in a single octet. `maxSegments` lowers that ceiling:
most handsets and SMSCs stop well short of 255, and refusing beats a message only half delivered.
### Receiving
@@ -143,6 +152,7 @@ if (err) throw err;
smpp.on('session', session => {
session.on('sms', async sms => {
// sms.from, sms.to, sms.message, sms.dlr
await sms.sendResp();
});
});
```
@@ -200,6 +210,20 @@ A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.f
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
| `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
### Bind direction
The three bind types are honoured in both directions, not just accepted. A receiver-bound ESME
carries no `submit_sm` and a transmitter-bound one is sent no `deliver_sm`, whichever end of the
link the session is:
- `session.sendSms()` on a receiver-bound session, and `sms.sendDlr()` to a transmitter-bound peer,
fail with an `err` before anything reaches the wire.
- A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one,
is answered `ESME_RINVBNDSTS`.
A `transceiver` bind, the default, carries both. `session.send()` stays a low-level passthrough and
is not checked, so the raw surface can still put whatever a test or a proxy needs on the wire.
## Errors
Nothing in this library throws. Every fallible call returns a result carrying an optional `err`, so
@@ -278,6 +302,9 @@ at and above which the spec allows optional parameters to be sent to it; `peerIn
the version it declared, `0x00` if it declared none. The library's own senders consult the first before attaching a TLV — a
`send()` you build yourself is passed through as written, so consult it too when you attach TLVs.
`bindAllows(cmdName)` answers the same question for the bind direction, and `boundAs` is the role
the ESME bound with — see [Bind direction](#bind-direction).
## Working with PDUs directly
The codec is exported, synchronous, and never throws — handy for inspecting captured traffic:
+5
View File
@@ -48,6 +48,11 @@ export default tseslint.config(
files: ['src/defs/encodings.ts'],
rules: { 'no-control-regex': 'off' },
},
{
// Mirrors the README's examples as written; an async event listener is part of what they show.
files: ['test/readme.test.ts'],
rules: { '@typescript-eslint/no-misused-promises': 'off' },
},
{
files: ['eslint.config.js'],
extends: [tseslint.configs.disableTypeChecked],
+4 -2
View File
@@ -1,7 +1,10 @@
import type { ConnectionOptions } from 'node:tls';
import type { Result, VoidResult } from './result.ts';
import type { BindType } from './session-options.ts';
import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net';
export type { BindType };
import { Session } from './session.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { connect as netConnect } from 'node:net';
@@ -9,8 +12,6 @@ import { connect as tlsConnect } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
import { silentLog } from './log.ts';
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
export type ClientOptions = {
addressRange?: string;
addrNpi?: number;
@@ -125,6 +126,7 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
session.boundAs = bindType;
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
+10
View File
@@ -48,6 +48,16 @@ export class IncomingRequests {
async handle(pduObj: PduObject): Promise<void> {
if (this.onRequest && await this.onRequest(this.session, pduObj)) return;
if (!this.session.bindAllows(pduObj.cmdName)) {
this.log.info('session - command the peer\'s bind direction does not carry', {
bindType: this.session.boundAs ?? '',
cmdName: pduObj.cmdName,
});
await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
return;
}
switch (pduObj.cmdName) {
case 'deliver_sm':
await this.onDeliverSm(pduObj);
+40 -21
View File
@@ -15,6 +15,8 @@ export type SendSmsOptions = {
encoding?: EncodingName;
flash?: boolean;
from: string;
/** Refuse before sending anything if the message needs more than this many segments. */
maxSegments?: number;
message: string;
scheduleDeliveryTime?: Date | number | string;
sourceAddrNpi?: number;
@@ -79,31 +81,26 @@ export function submitSmParams(
}
/** 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 });
if (segments.length === 0) {
return {
err: new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`),
pduObjs: [],
smsIds: [],
};
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
function checkSegments(allowed: number, segments: number): Error | undefined {
if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) {
return new Error(`maxSegments must be between 1 and ${String(maxSegments)}, got ${String(allowed)}`);
}
const multipart = segments.length > 1;
if (segments === 0) {
return new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`);
}
if (segments > allowed) {
return new Error(`Message needs ${String(segments)} segments, more than the ${String(allowed)} allowed`);
}
return undefined;
}
function collectSent(sent: Result<{ pduObj: PduObject }>[]): SendSmsResult {
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 }),
})));
let failure: Error | undefined;
for (const one of sent) {
@@ -121,3 +118,25 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise
return failure ? { err: failure, pduObjs, smsIds } : { pduObjs, smsIds };
}
export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise<SendSmsResult> {
const allowed = sms.maxSegments ?? maxSegments;
const encoding = sms.encoding ?? detect(sms.message);
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
const refused = checkSegments(allowed, segments.length);
if (refused) return { err: refused, pduObjs: [], smsIds: [] };
const multipart = segments.length > 1;
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 }),
})));
return collectSent(sent);
}
+2 -1
View File
@@ -5,7 +5,7 @@ import type { Server as TlsServer, TlsOptions } from 'node:tls';
import type { SmppLog } from './log.ts';
import { EventEmitter } from 'node:events';
import { Session, bindCommands, defaultSystemId } from './session.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { bindTypeFromCommand, checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -147,6 +147,7 @@ async function acceptBind(
): Promise<void> {
const declared = pduObj.params.interface_version;
session.boundAs = bindTypeFromCommand(pduObj.cmdName);
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
+22
View File
@@ -25,6 +25,28 @@ export const bindCommands: readonly string[] = [
'bind_transmitter',
];
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
export function bindTypeFromCommand(cmdName: string): BindType | undefined {
if (cmdName === 'bind_receiver') return 'receiver';
if (cmdName === 'bind_transceiver') return 'transceiver';
if (cmdName === 'bind_transmitter') return 'transmitter';
return undefined;
}
/**
* Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a
* transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that
* has not bound carries everything, since nothing has declared a direction yet.
*/
export function bindCarries(bindType: BindType | undefined, cmdName: string): boolean {
if (bindType === 'receiver') return cmdName !== 'submit_sm';
if (bindType === 'transmitter') return cmdName !== 'deliver_sm';
return true;
}
export type SendOptions = { signal?: AbortSignal | undefined };
/**
+14 -2
View File
@@ -2,7 +2,7 @@ import type { ErrorName } from './defs/errors.ts';
import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { BindType, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts';
@@ -16,7 +16,7 @@ import { PendingRequests } from './pending-requests.ts';
import { ReconnectLoop } from './reconnect-loop.ts';
import { SendWindow } from './send-window.ts';
import { optionalParamsMinVersion } from './defs/constants.ts';
import { bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
import { silentLog } from './log.ts';
import { submitSms } from './send-sms.ts';
@@ -30,6 +30,7 @@ export type {
SessionEvents,
SessionOptions,
};
export type { BindType };
export { bindCommands, defaultSystemId };
export class Session extends EventEmitter<SessionEvents> {
@@ -37,6 +38,8 @@ export class Session extends EventEmitter<SessionEvents> {
sock: Socket;
readonly log: SmppLog;
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
boundAs: BindType | undefined = undefined;
loggedIn = false;
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
peerInterfaceVersion: number | undefined = undefined;
@@ -110,6 +113,11 @@ export class Session extends EventEmitter<SessionEvents> {
this.resetTimers();
}
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */
bindAllows(cmdName: string): boolean {
return bindCarries(this.boundAs, cmdName);
}
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
acceptsOptionalParams(): boolean {
return this.peerInterfaceVersion === undefined
@@ -165,6 +173,10 @@ export class Session extends EventEmitter<SessionEvents> {
}
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
if (!this.bindAllows('submit_sm')) {
return { err: new Error('A receiver-bound session does not carry submit_sm'), pduObjs: [], smsIds: [] };
}
const sent = await submitSms({
log: this.log,
reference: this.nextConcatReference(),
+4
View File
@@ -126,6 +126,10 @@ async function sendDlr(
sms: Sms,
status: MessageState = 'DELIVERED',
): Promise<Result<{ pduObjs: PduObject[] }>> {
if (!sms.session.bindAllows('deliver_sm')) {
return { err: new Error('A transmitter-bound session does not carry deliver_sm') };
}
const total = sms.pduObjs.length;
const pduObjs: PduObject[] = [];
+235
View File
@@ -0,0 +1,235 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import type { Dlr } from '../src/dlr.ts';
import type { Session } from '../src/session.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppLog } from '../src/log.ts';
import type { SmppServer } from '../src/server.ts';
import type { TestContext } from 'node:test';
import { client } from '../src/client.ts';
import { server } from '../src/server.ts';
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('waited 5000 ms for an event that never fired'));
}, 5000);
register(value => {
clearTimeout(timer);
resolve(value);
});
});
}
/** The README's examples listen on the documented default port, so one server runs at a time. */
async function answeringServer(t: TestContext): Promise<SmppServer> {
const { err, server: smpp } = await server();
assert.equal(err, undefined);
assert.ok(smpp);
t.after(() => smpp.close());
smpp.on('session', session => {
session.on('sms', async sms => {
await sms.sendResp();
if (sms.dlr) await sms.sendDlr();
});
});
return smpp;
}
describe('README: Client', () => {
test('the simplest possible client', async t => {
await answeringServer(t);
const { err, session } = await client();
if (err) throw err;
await session.sendSms({
from: '46701113311',
message: 'Hello world',
to: '46709771337',
});
await session.unbind();
});
test('with connection parameters, a delivery report and logging', async t => {
await answeringServer(t);
const log: SmppLog = {
debug: () => undefined,
error: () => undefined,
info: () => undefined,
verbose: () => undefined,
warn: () => undefined,
};
const { err, session } = await client({
host: 'localhost',
log,
password: 'bar',
port: 2775,
username: 'foo',
});
if (err) throw err;
t.after(() => { session.close(); });
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
const { err: sendErr, smsIds } = await session.sendSms({
dlr: true,
from: '46701113311',
message: '«baff»',
to: '46709771337',
});
assert.equal(sendErr, undefined);
assert.equal(smsIds.length, 1);
assert.equal((await reported).smsId, smsIds[0]);
});
test('the documented sending options', async t => {
const smpp = await answeringServer(t);
const incoming = once<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const { err, session } = await client();
if (err) throw err;
t.after(() => { session.close(); });
const { signal } = new AbortController();
const [sms, sent] = await Promise.all([
incoming,
session.sendSms({
dlr: true,
encoding: 'UCS2',
flash: false,
from: 'MyBrand',
message: 'Hello world',
scheduleDeliveryTime: new Date(Date.now() + 3600_000),
to: '46709771337',
validityPeriod: 3600,
}, { signal }),
]);
assert.equal(sent.err, undefined);
assert.equal(sms.from, 'MyBrand');
assert.equal(sms.message, 'Hello world');
});
test('receiving an inbound message on a client session', async t => {
const smpp = await answeringServer(t);
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
const { err, session } = await client();
if (err) throw err;
t.after(() => { session.close(); });
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
const peer = await bound;
void peer.send({
cmdName: 'deliver_sm',
params: {
destination_addr: '46709771337',
short_message: 'inbound hello',
source_addr: '46701113311',
},
});
const sms = await incoming;
await sms.sendResp();
assert.equal(sms.message, 'inbound hello');
});
});
describe('README: Server', () => {
test('the simplest possible server', async t => {
const { err, server: smpp } = await server();
if (err) throw err;
t.after(() => smpp.close());
const received: string[] = [];
smpp.on('session', session => {
session.on('sms', async sms => {
received.push(sms.message);
await sms.sendResp();
});
});
const { err: clientErr, session } = await client();
assert.equal(clientErr, undefined);
assert.ok(session);
await session.sendSms({ from: '46701113311', message: 'Hello world', to: '46709771337' });
await session.unbind();
assert.deepEqual(received, ['Hello world']);
});
test('with authentication and delivery reports', async t => {
const { err, server: smpp } = await server({
authenticate: ({ password, systemId }) => {
if (systemId !== 'foo' || password !== 'bar') return false;
return { userData: { userId: 123 } };
},
});
if (err) throw err;
t.after(() => smpp.close());
smpp.on('session', session => {
session.on('sms', async sms => {
await sms.sendResp();
if (sms.dlr) {
await sms.sendDlr();
}
});
});
assert.equal(smpp.port, 2775);
const refused = await client({ password: 'wrong', username: 'foo' });
assert.ok(refused.err instanceof Error);
const { err: clientErr, session } = await client({ password: 'bar', username: 'foo' });
assert.equal(clientErr, undefined);
assert.ok(session);
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
const sent = await session.sendSms({
dlr: true,
from: '46701113311',
message: 'with a receipt',
to: '46709771337',
});
assert.equal(sent.err, undefined);
assert.equal((await reported).statusMsg, 'DELIVERED');
await session.unbind();
});
});
describe('README: Errors', () => {
test('a refused connection reports err instead of throwing', async () => {
const { err, session } = await client({ port: 1 });
assert.ok(err instanceof Error);
assert.equal(session, undefined);
});
});
+25
View File
@@ -243,6 +243,31 @@ describe('sendSms()', () => {
assert.ok(sent.err instanceof Error);
assert.equal(attempts.length, 0);
});
// Most handsets and SMSCs stop well short of the 255 a UDH can number.
test('refuses a message needing more segments than the caller allows', async () => {
const attempts: PduObjectInput[] = [];
const deps = {
log: silentLog,
reference: 3,
send: (input: PduObjectInput) => {
attempts.push(input);
return Promise.resolve({ pduObj: submitResp(attempts.length, `landed-${String(attempts.length)}`) });
},
};
const message = 'a'.repeat(153 * 4);
const refused = await submitSms(deps, { from: '46701113311', maxSegments: 3, message, to: '46709771337' });
assert.ok(refused.err instanceof Error);
assert.equal(attempts.length, 0);
const sent = await submitSms(deps, { from: '46701113311', maxSegments: 4, message, to: '46709771337' });
assert.equal(sent.err, undefined);
assert.equal(attempts.length, 4);
});
});
describe('reconnect', () => {
+121
View File
@@ -424,6 +424,93 @@ describe('bind', () => {
});
});
describe('bind direction', () => {
// A receiver-bound ESME sends no submit_sm and a transmitter-bound one is sent no deliver_sm.
test('refuses a submit_sm from a peer that bound as a receiver', async () => {
const smpp = await startServer();
const { session } = await connect(smpp, { bindType: 'receiver' });
assert.ok(session);
const sent = await session.send({
cmdName: 'submit_sm',
params: { destination_addr: '46709771337', short_message: 'nope', source_addr: '46701113311' },
});
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
session.close();
await smpp.close();
});
test('refuses sendSms() on a receiver-bound session before it reaches the wire', async () => {
const smpp = await startServer();
const arrived: Sms[] = [];
smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms)));
const { session } = await connect(smpp, { bindType: 'receiver' });
assert.ok(session);
const sent = await session.sendSms({ from: '46701113311', message: 'nope', to: '46709771337' });
assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /receiver-bound/);
assert.deepEqual(sent.smsIds, []);
assert.equal(arrived.length, 0);
session.close();
await smpp.close();
});
test('refuses a deliver_sm sent to a peer that bound as a transmitter', async () => {
const smpp = await startServer();
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
const { session } = await connect(smpp, { bindType: 'transmitter' });
assert.ok(session);
const peer = await bound;
const sent = await peer.send({
cmdName: 'deliver_sm',
params: { destination_addr: '46709771337', short_message: 'nope', source_addr: '46701113311' },
});
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
session.close();
await smpp.close();
});
test('refuses sendDlr() to a transmitter-bound peer before it reaches the wire', async () => {
const smpp = await startServer();
const incoming = once<Sms>(resolve => {
smpp.on('session', peer => peer.on('sms', resolve));
});
const { session } = await connect(smpp, { bindType: 'transmitter' });
assert.ok(session);
const [sms] = await Promise.all([
incoming,
session.sendSms({ dlr: true, from: '46701113311', message: 'one way', to: '46709771337' }),
]);
await sms.sendResp();
const report = await sms.sendDlr();
assert.ok(report.err instanceof Error);
assert.match(report.err.message, /transmitter-bound/);
session.close();
await smpp.close();
});
});
describe('sending', () => {
test('delivers a simple SMS with the sender TON derived from the address', async () => {
const smpp = await startServer();
@@ -546,6 +633,40 @@ describe('sending', () => {
session.close();
await smpp.close();
});
test('puts the address TON and NPI the caller chose on the wire', async () => {
const smpp = await startServer();
const incoming = once<Sms>(resolve => {
smpp.on('session', peer => peer.on('sms', resolve));
});
const { session } = await connect(smpp);
assert.ok(session);
const [sms] = await Promise.all([
incoming,
session.sendSms({
destinationAddrNpi: consts.NPI.ISDN,
destinationAddrTon: consts.TON.NATIONAL,
from: '46701113311',
message: 'addressed by hand',
sourceAddrNpi: consts.NPI.PRIVATE,
sourceAddrTon: consts.TON.ABBREVIATED,
to: '46709771337',
}),
]);
const params = sms.pduObjs[0]?.params;
assert.ok(params);
assert.equal(params.dest_addr_npi, consts.NPI.ISDN);
assert.equal(params.dest_addr_ton, consts.TON.NATIONAL);
assert.equal(params.source_addr_npi, consts.NPI.PRIVATE);
assert.equal(params.source_addr_ton, consts.TON.ABBREVIATED);
session.close();
await smpp.close();
});
});
describe('receiving', () => {
+50 -3
View File
@@ -5,7 +5,7 @@ rules there constrain every item below.
## Status
The rewrite is **feature complete and green**: 210 tests, lint and typecheck clean, verified on Node
The rewrite is **feature complete and green**: 223 tests, lint and typecheck clean, verified on Node
18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
```bash
@@ -54,12 +54,50 @@ Rules the API follows:
| Stream framing | `test/pdu-framer.test.ts` |
| Delivery receipt parsing, TLV and text | `test/dlr.test.ts` |
| Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window | `test/session.test.ts` |
| Merged multipart DLRs, reconnect, reassembly bounds, per-send abort | `test/session-extras.test.ts` |
| Merged multipart DLRs, reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` |
| Every runnable README example | `test/readme.test.ts` |
| Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` |
| CI on Node 18/20/22/24, Renovate, tag-triggered publish | `.github/workflows/` |
Every defect listed in the AGENTS.md table has a regression test naming the behaviour.
## The GitHub backlog, once this branch is `master`
Nothing below is closed while `master` is still 0.4.0 — declining a security bump on a live default
branch is worse than leaving it open. Work through this immediately after the merge.
**Close as fixed by 1.0.0**, naming the replacement in the comment:
| | Fixed by |
| --- | --- |
| [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing | `dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent |
| [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw `Buffer` segments | `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms` event |
| [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages | `test/session.test.ts` |
| [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency versions | The mocha suite is gone; `node:test` on Node 18/20/22/24 |
| [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples | `test/readme.test.ts` |
| [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be settable | `sendSms()` takes all four, documented and tested |
| [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types | Bound and enforced in both directions |
| [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments | The `maxSegments` send option |
| [#68](https://github.com/larvit/larvitsmpp/pull/68) `message_id` in `submit_sm_resp`, spec DLR codes | All four hold: `sendResp()` always answers a `message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real defects. |
**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`,
`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`:
[#40](https://github.com/larvit/larvitsmpp/pull/40), [#41](https://github.com/larvit/larvitsmpp/pull/41),
[#42](https://github.com/larvit/larvitsmpp/pull/42), [#45](https://github.com/larvit/larvitsmpp/pull/45),
[#46](https://github.com/larvit/larvitsmpp/pull/46), [#47](https://github.com/larvit/larvitsmpp/pull/47),
[#59](https://github.com/larvit/larvitsmpp/pull/59), [#63](https://github.com/larvit/larvitsmpp/pull/63),
[#64](https://github.com/larvit/larvitsmpp/pull/64), [#65](https://github.com/larvit/larvitsmpp/pull/65),
[#67](https://github.com/larvit/larvitsmpp/pull/67), [#70](https://github.com/larvit/larvitsmpp/pull/70).
[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports on the
default branch; it disappears with the runtime dependencies rather than being fixed.
[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — leave it, it
re-baselines itself against the new `package.json`.
**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and
port on log messages. Only `server - incoming connection` carries them today; putting them on every
session message is a change to every call site.
## Before publishing 1.0.0
- [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which
@@ -71,10 +109,19 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha
## Worth doing, not blocking
- [ ] **An `async` event listener that rejects escapes the guard.** `Session.emit()` wraps
`super.emit()` in try/catch, which catches a listener that throws synchronously but not one
that returns a rejected promise — that surfaces as an unhandled rejection and takes the
process down, which hard rule 1 says must not happen. Every README example uses
`session.on('sms', async sms => …)`, so the shape is the one applications will write. The
library's own calls inside such a listener never reject, so the examples themselves are safe.
Fixing it means dispatching `rawListeners()` by hand in `emit()` and routing a rejection to
`sessionError` — a change to the hottest path, so it needs a decision before 1.0.0.
- [ ] **In-flight sends across a reconnect.** They currently fail with "Session closed before a
response arrived" and the caller retries. Re-queueing them automatically would be friendlier
but risks duplicate delivery, so it needs a decision before it is built.
- [ ] **`session.ts` is 459 lines.** The one seam left in it is a socket-to-PDU transport, which
- [ ] **`session.ts` is 386 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.
- [ ] **Group the session's collaborators under `src/session/`.** Only `session.ts` imports