Enforce bind direction, cap segments per message and pin the README examples
This commit is contained in:
@@ -55,7 +55,7 @@ src/
|
|||||||
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-sms.ts submitSms composition and the submitSmParams builder
|
||||||
send-window.ts SendWindow: the maxOutstanding semaphore
|
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
|
udh.ts User data header: the concatenation fields of a long SMS
|
||||||
uuid.ts uuidv7() — the ids the library generates for messages
|
uuid.ts uuidv7() — the ids the library generates for messages
|
||||||
defs/
|
defs/
|
||||||
@@ -185,6 +185,13 @@ exactly 140.
|
|||||||
`server()`, so an implementation that needs 5.0 throughout can have it. The threshold at or above
|
`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
|
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.
|
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
|
- **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
|
`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
|
application can satisfy it with an object literal and `@larvit/smpp` ships with no runtime
|
||||||
|
|||||||
@@ -92,16 +92,24 @@ Every one is optional.
|
|||||||
```javascript
|
```javascript
|
||||||
await session.sendSms({
|
await session.sendSms({
|
||||||
dlr: true, // ask for a delivery report
|
dlr: true, // ask for a delivery report
|
||||||
|
destinationAddrNpi: 0, // override the numbering plan of the recipient
|
||||||
|
destinationAddrTon: 1,
|
||||||
encoding: 'UCS2', // override the automatic choice
|
encoding: 'UCS2', // override the automatic choice
|
||||||
flash: false,
|
flash: false,
|
||||||
from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1
|
from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1
|
||||||
|
maxSegments: 10, // refuse a longer message instead of sending it
|
||||||
message: 'Hello world',
|
message: 'Hello world',
|
||||||
scheduleDeliveryTime: new Date(Date.now() + 3600_000),
|
scheduleDeliveryTime: new Date(Date.now() + 3600_000),
|
||||||
|
sourceAddrNpi: 0, // override the numbering plan of the sender
|
||||||
|
sourceAddrTon: 5,
|
||||||
to: '46709771337',
|
to: '46709771337',
|
||||||
validityPeriod: 3600, // seconds, or a Date
|
validityPeriod: 3600, // seconds, or a Date
|
||||||
}, { signal }); // optional per-call AbortSignal
|
}, { 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
|
Messages too long for one SMS are split automatically and sent as a concatenated message. You get
|
||||||
one id per segment:
|
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
|
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
|
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
|
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
|
### Receiving
|
||||||
|
|
||||||
@@ -143,6 +152,7 @@ if (err) throw err;
|
|||||||
smpp.on('session', session => {
|
smpp.on('session', session => {
|
||||||
session.on('sms', async sms => {
|
session.on('sms', async sms => {
|
||||||
// sms.from, sms.to, sms.message, sms.dlr
|
// 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. |
|
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
|
||||||
| `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
|
| `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
|
## Errors
|
||||||
|
|
||||||
Nothing in this library throws. Every fallible call returns a result carrying an optional `err`, so
|
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
|
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.
|
`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
|
## Working with PDUs directly
|
||||||
|
|
||||||
The codec is exported, synchronous, and never throws — handy for inspecting captured traffic:
|
The codec is exported, synchronous, and never throws — handy for inspecting captured traffic:
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export default tseslint.config(
|
|||||||
files: ['src/defs/encodings.ts'],
|
files: ['src/defs/encodings.ts'],
|
||||||
rules: { 'no-control-regex': 'off' },
|
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'],
|
files: ['eslint.config.js'],
|
||||||
extends: [tseslint.configs.disableTypeChecked],
|
extends: [tseslint.configs.disableTypeChecked],
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,10 @@
|
|||||||
import type { ConnectionOptions } from 'node:tls';
|
import type { ConnectionOptions } from 'node:tls';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
|
import type { BindType } from './session-options.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
|
export type { BindType };
|
||||||
|
|
||||||
import { Session } from './session.ts';
|
import { Session } from './session.ts';
|
||||||
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
|
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
|
||||||
import { connect as netConnect } from 'node:net';
|
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 { defaultInterfaceVersion } from './defs/constants.ts';
|
||||||
import { silentLog } from './log.ts';
|
import { silentLog } from './log.ts';
|
||||||
|
|
||||||
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
|
|
||||||
|
|
||||||
export type ClientOptions = {
|
export type ClientOptions = {
|
||||||
addressRange?: string;
|
addressRange?: string;
|
||||||
addrNpi?: number;
|
addrNpi?: number;
|
||||||
@@ -125,6 +126,7 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
|
|||||||
|
|
||||||
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
|
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue;
|
||||||
|
|
||||||
|
session.boundAs = bindType;
|
||||||
session.loggedIn = true;
|
session.loggedIn = true;
|
||||||
session.peerInterfaceVersion = typeof declared === 'number'
|
session.peerInterfaceVersion = typeof declared === 'number'
|
||||||
? declared
|
? declared
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ export class IncomingRequests {
|
|||||||
async handle(pduObj: PduObject): Promise<void> {
|
async handle(pduObj: PduObject): Promise<void> {
|
||||||
if (this.onRequest && await this.onRequest(this.session, pduObj)) return;
|
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) {
|
switch (pduObj.cmdName) {
|
||||||
case 'deliver_sm':
|
case 'deliver_sm':
|
||||||
await this.onDeliverSm(pduObj);
|
await this.onDeliverSm(pduObj);
|
||||||
|
|||||||
+40
-21
@@ -15,6 +15,8 @@ export type SendSmsOptions = {
|
|||||||
encoding?: EncodingName;
|
encoding?: EncodingName;
|
||||||
flash?: boolean;
|
flash?: boolean;
|
||||||
from: string;
|
from: string;
|
||||||
|
/** Refuse before sending anything if the message needs more than this many segments. */
|
||||||
|
maxSegments?: number;
|
||||||
message: string;
|
message: string;
|
||||||
scheduleDeliveryTime?: Date | number | string;
|
scheduleDeliveryTime?: Date | number | string;
|
||||||
sourceAddrNpi?: number;
|
sourceAddrNpi?: number;
|
||||||
@@ -79,31 +81,26 @@ export function submitSmParams(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Puts a message on the wire as one submit_sm per segment. */
|
/** Puts a message on the wire as one submit_sm per segment. */
|
||||||
export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise<SendSmsResult> {
|
/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */
|
||||||
const encoding = sms.encoding ?? detect(sms.message);
|
function checkSegments(allowed: number, segments: number): Error | undefined {
|
||||||
const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
|
if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) {
|
||||||
|
return new Error(`maxSegments must be between 1 and ${String(maxSegments)}, got ${String(allowed)}`);
|
||||||
if (segments.length === 0) {
|
|
||||||
return {
|
|
||||||
err: new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`),
|
|
||||||
pduObjs: [],
|
|
||||||
smsIds: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const multipart = segments.length > 1;
|
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 pduObjs: PduObject[] = [];
|
||||||
const smsIds: string[] = [];
|
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;
|
let failure: Error | undefined;
|
||||||
|
|
||||||
for (const one of sent) {
|
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 };
|
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
@@ -5,7 +5,7 @@ import type { Server as TlsServer, TlsOptions } from 'node:tls';
|
|||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session, bindCommands, defaultSystemId } from './session.ts';
|
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 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 { defaultInterfaceVersion } from './defs/constants.ts';
|
||||||
@@ -147,6 +147,7 @@ async function acceptBind(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const declared = pduObj.params.interface_version;
|
const declared = pduObj.params.interface_version;
|
||||||
|
|
||||||
|
session.boundAs = bindTypeFromCommand(pduObj.cmdName);
|
||||||
session.loggedIn = true;
|
session.loggedIn = true;
|
||||||
session.peerInterfaceVersion = typeof declared === 'number'
|
session.peerInterfaceVersion = typeof declared === 'number'
|
||||||
? declared
|
? declared
|
||||||
|
|||||||
@@ -25,6 +25,28 @@ export const bindCommands: readonly string[] = [
|
|||||||
'bind_transmitter',
|
'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 };
|
export type SendOptions = { signal?: AbortSignal | undefined };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+14
-2
@@ -2,7 +2,7 @@ import type { ErrorName } from './defs/errors.ts';
|
|||||||
import type { MessageDlr } from './dlr-merger.ts';
|
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 { 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 { Result, VoidResult } from './result.ts';
|
||||||
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
@@ -16,7 +16,7 @@ import { PendingRequests } from './pending-requests.ts';
|
|||||||
import { ReconnectLoop } from './reconnect-loop.ts';
|
import { ReconnectLoop } from './reconnect-loop.ts';
|
||||||
import { SendWindow } from './send-window.ts';
|
import { SendWindow } from './send-window.ts';
|
||||||
import { optionalParamsMinVersion } from './defs/constants.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 { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts';
|
||||||
import { silentLog } from './log.ts';
|
import { silentLog } from './log.ts';
|
||||||
import { submitSms } from './send-sms.ts';
|
import { submitSms } from './send-sms.ts';
|
||||||
@@ -30,6 +30,7 @@ export type {
|
|||||||
SessionEvents,
|
SessionEvents,
|
||||||
SessionOptions,
|
SessionOptions,
|
||||||
};
|
};
|
||||||
|
export type { BindType };
|
||||||
export { bindCommands, defaultSystemId };
|
export { bindCommands, defaultSystemId };
|
||||||
|
|
||||||
export class Session extends EventEmitter<SessionEvents> {
|
export class Session extends EventEmitter<SessionEvents> {
|
||||||
@@ -37,6 +38,8 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
sock: Socket;
|
sock: Socket;
|
||||||
readonly log: SmppLog;
|
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;
|
loggedIn = false;
|
||||||
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
|
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
|
||||||
peerInterfaceVersion: number | undefined = undefined;
|
peerInterfaceVersion: number | undefined = undefined;
|
||||||
@@ -110,6 +113,11 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
this.resetTimers();
|
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. */
|
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
|
||||||
acceptsOptionalParams(): boolean {
|
acceptsOptionalParams(): boolean {
|
||||||
return this.peerInterfaceVersion === undefined
|
return this.peerInterfaceVersion === undefined
|
||||||
@@ -165,6 +173,10 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
|
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({
|
const sent = await submitSms({
|
||||||
log: this.log,
|
log: this.log,
|
||||||
reference: this.nextConcatReference(),
|
reference: this.nextConcatReference(),
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ async function sendDlr(
|
|||||||
sms: Sms,
|
sms: Sms,
|
||||||
status: MessageState = 'DELIVERED',
|
status: MessageState = 'DELIVERED',
|
||||||
): Promise<Result<{ pduObjs: PduObject[] }>> {
|
): 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 total = sms.pduObjs.length;
|
||||||
const pduObjs: PduObject[] = [];
|
const pduObjs: PduObject[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -243,6 +243,31 @@ describe('sendSms()', () => {
|
|||||||
assert.ok(sent.err instanceof Error);
|
assert.ok(sent.err instanceof Error);
|
||||||
assert.equal(attempts.length, 0);
|
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', () => {
|
describe('reconnect', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
describe('sending', () => {
|
||||||
test('delivers a simple SMS with the sender TON derived from the address', async () => {
|
test('delivers a simple SMS with the sender TON derived from the address', async () => {
|
||||||
const smpp = await startServer();
|
const smpp = await startServer();
|
||||||
@@ -546,6 +633,40 @@ describe('sending', () => {
|
|||||||
session.close();
|
session.close();
|
||||||
await smpp.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', () => {
|
describe('receiving', () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ rules there constrain every item below.
|
|||||||
|
|
||||||
## Status
|
## 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.
|
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
|
||||||
@@ -54,12 +54,50 @@ Rules the API follows:
|
|||||||
| Stream framing | `test/pdu-framer.test.ts` |
|
| Stream framing | `test/pdu-framer.test.ts` |
|
||||||
| Delivery receipt parsing, TLV and text | `test/dlr.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` |
|
| 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` |
|
| 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/` |
|
| 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.
|
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
|
## Before publishing 1.0.0
|
||||||
|
|
||||||
- [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which
|
- [ ] 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
|
## 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
|
- [ ] **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
|
- [ ] **`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 —
|
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.
|
a public-surface change, so it waits for a decision.
|
||||||
- [ ] **Group the session's collaborators under `src/session/`.** Only `session.ts` imports
|
- [ ] **Group the session's collaborators under `src/session/`.** Only `session.ts` imports
|
||||||
|
|||||||
Reference in New Issue
Block a user