Document the finished API and make the test scripts portable across Node versions

This commit is contained in:
2026-08-25 17:02:57 +02:00
parent b671ebafb2
commit 54b73205ac
4 changed files with 173 additions and 211 deletions
+16
View File
@@ -102,6 +102,18 @@ implementation (see todo.md).
| Dormant filters | `defs.filters` is declared on commands and TLVs but never invoked anywhere. Dropped in the rewrite; SMPP time formatting is exported as `smppTime` instead | | Dormant filters | `defs.filters` is declared on commands and TLVs but never invoked anywhere. Dropped in the rewrite; SMPP time formatting is exported as `smppTime` instead |
| Unchecked reads | Wire reads index straight into the buffer, so a short or malformed PDU throws out of the codec. Reads are bounds-checked and return results now | | Unchecked reads | Wire reads index straight into the buffer, so a short or malformed PDU throws out of the codec. Reads are bounds-checked and return results now |
| Unrangechecked writes | Integer params are handed to `writeUInt8`/`writeUInt16BE` unvalidated, so an out-of-range value throws from inside Node | | Unrangechecked writes | Integer params are handed to `writeUInt8`/`writeUInt16BE` unvalidated, so an out-of-range value throws from inside Node |
| `submit_multi` missing `sm_length` | The field is commented out of the command table, so `short_message` never round-trips for that command |
| Per-parameter defaults never applied | `calcCmdLength` reads `paramType.default` (the wire type's) rather than the parameter's, so `interface_version: 0x50` on the bind commands did nothing and every bind declared version 0x00 |
| `ESME_RINVBCASTCHANIND` typo | Defined as `0x011`, three hex digits; the spec value is `0x0112` |
## Multipart sends and the send window
`sendSms` puts every segment of a message on the wire together instead of waiting for each response
in turn. This is not an optimisation: this library's own server holds segments until the whole
message is reassembled before it answers any of them, so sending them one-after-a-response
deadlocks. It follows that a message with more segments than `maxOutstanding` cannot be delivered to
a server that defers responses that way — real SMSCs answer each `submit_sm` immediately, so this
only bites when both ends are this library.
## GSM 7-bit is sent unpacked ## GSM 7-bit is sent unpacked
@@ -123,3 +135,7 @@ exactly 140.
preambles or restate what the code says. preambles or restate what the code says.
- Test data uses real randomised UUID v7 values, never `aaaa-0000` placeholders. - Test data uses real randomised UUID v7 values, never `aaaa-0000` placeholders.
- `message_id` values the library generates are UUID v7. - `message_id` values the library generates are UUID v7.
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
- `assert.equal` from `node:assert/strict` narrows its first argument, so a following `?.` on the
same value is flagged as unnecessary. Assert once with `assert.ok(x)` and use plain access after.
+102 -16
View File
@@ -6,13 +6,13 @@ Successor to [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The A
it has always been — connect, send an SMS, listen for delivery reports — with callbacks replaced by it has always been — connect, send an SMS, listen for delivery reports — with callbacks replaced by
promises and the rough edges taken off. promises and the rough edges taken off.
> **Status: not released.** This branch is a rewrite in progress and nothing here is published yet. > **Not published yet.** The implementation is complete and tested, but 1.0.0 has not been released
> The API below is the agreed design and is being implemented against it; see > to npm. Until it is, use `larvitsmpp` 0.4.0. Remaining release steps are in [todo.md](todo.md).
> [todo.md](todo.md) for what is done and what is not. Use `larvitsmpp` 0.4.0 until 1.0.0 ships.
## Requirements ## Requirements
Node 18 or later. Node 18 or later. The only runtime dependency is
[`@larvit/log`](https://www.npmjs.com/package/@larvit/log).
## Install ## Install
@@ -68,6 +68,46 @@ const { err: sendErr, smsIds } = await session.sendSms({
}); });
``` ```
### Client options
Every one is optional.
| Option | Default | |
| --- | --- | --- |
| `host` / `port` | `localhost` / `2775` | Where to connect. |
| `username` / `password` | `user` / `pass` | Bind credentials (`system_id` and `password`). |
| `bindType` | `transceiver` | `transceiver`, `transmitter` or `receiver`. |
| `systemType`, `addressRange`, `addrTon`, `addrNpi`, `interfaceVersion` | `''`, `''`, `0`, `0`, `0x50` | The remaining bind fields, for operators that require them. |
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
| `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. |
| `responseTimeout` | `30000` | How long to wait for a response before giving up on it. |
| `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. |
| `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop, with exponential backoff. |
| `log` | silent | A `@larvit/log` instance. |
| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
### Sending
```javascript
await session.sendSms({
dlr: true, // ask for a delivery report
encoding: 'UCS2', // override the automatic choice
flash: false,
from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1
message: 'Hello world',
scheduleDeliveryTime: new Date(Date.now() + 3600_000),
to: '46709771337',
validityPeriod: 3600, // seconds, or a Date
}, { signal }); // optional per-call AbortSignal
```
Messages too long for one SMS are split automatically and sent as a concatenated message. You get
one id per segment:
```javascript
const { err, pduObjs, smsIds } = await session.sendSms({ from, message, to });
```
## Server ## Server
The simplest possible server — no authentication, listening on port 2775: The simplest possible server — no authentication, listening on port 2775:
@@ -112,12 +152,25 @@ smpp.on('session', session => {
}); });
}); });
await smpp.close(); console.log(smpp.port); // the port actually bound, useful when 0 was requested
await smpp.close(); // stop listening and close every live session
``` ```
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, `sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`. `ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`.
### Server options
| Option | Default | |
| --- | --- | --- |
| `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. |
| `tls` | `false` | `true`, or a `tls.TlsOptions` object with your certificate and key. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
| `reassemblyTimeout` | `300000` | How long an incomplete multipart message is held. |
| `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
## 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
@@ -140,10 +193,10 @@ is exactly what this library promises not to do.
| Event | Fires when | | Event | Fires when |
| --- | --- | | --- | --- |
| `sms` | An SMS arrives. Carries `sendResp()` and `sendDlr()`. | | `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()` and `sendDlr()`. |
| `dlr` | A delivery report arrives, one per segment. | | `dlr` | A delivery report arrives, one per segment. |
| `messageDlr` | Every segment of a multipart message has been reported on. | | `messageDlr` | Every segment of a multipart message has been reported on. |
| `close` | The socket closed. | | `close` | The connection closed. |
| `reconnected` | The client re-bound after a drop (only with `reconnect` configured). | | `reconnected` | The client re-bound after a drop (only with `reconnect` configured). |
| `sessionError` | Something failed on a live session. | | `sessionError` | Something failed on a live session. |
| `data` | Raw bytes arrived on the socket. | | `data` | Raw bytes arrived on the socket. |
@@ -153,22 +206,48 @@ is exactly what this library promises not to do.
### Methods ### Methods
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. `send()` reaches any of the 33 SMPP `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. `send()` reaches any of the 33 SMPP
commands the codec knows, not just the four the session handles natively. commands the codec knows, not just the four the session handles natively:
```javascript
const { err, pduObj } = await session.send({
cmdName: 'query_sm',
params: { message_id: smsId },
});
```
## Working with PDUs directly
The codec is exported, synchronous, and never throws — handy for inspecting captured traffic:
```javascript
import { isCommand, objToPdu, pduToObj } from '@larvit/smpp';
const { err, pduObj } = pduToObj(buffer);
if (err) return;
if (isCommand(pduObj, 'submit_sm')) {
pduObj.params.destination_addr; // typed as a string
}
```
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
`types`, and the matching `*ById` maps) and grouped as `defs`.
## Migrating from larvitsmpp 0.4.0 ## Migrating from larvitsmpp 0.4.0
- **The package is now `@larvit/smpp`** and is ESM only. `require()` no longer works. - **The package is now `@larvit/smpp`** and is ESM only. `require()` no longer works.
- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp` and `sendDlr` are all promises - **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp` and `sendDlr` are all promises
resolving to a result object with an optional `err`. Nothing rejects. resolving to a result object with an optional `err`. Nothing rejects.
- **`server()` resolves once, when it is listening**, and gives you a handle with `close()` and a - **`server()` resolves once, when it is listening**, and gives you a handle with `close()`, `port`
`session` event. It no longer calls your callback once per incoming connection. and a `session` event. It no longer calls your callback once per incoming connection.
- **`checkuserpass` is now `authenticate`**, takes `{ password, systemId }` and returns `false` or - **`checkuserpass` is now `authenticate`**, takes `{ password, session, systemId, systemType }` and
`{ userData }`. returns `false` or `{ userData }`.
- **Renamed options:** `enqLinkTiming``enquireLinkInterval`, server `timeout``idleTimeout`. - **Renamed options:** `enqLinkTiming``enquireLinkInterval`, server `timeout``idleTimeout`.
- **`larvitsmpp.utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`, - **`larvitsmpp.utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`,
`encodeMessage`, `objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `smppTime`, `splitMessage`. The PDU codec is `encodeMessage`, `objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `smppTime`, `splitMessage`. The
synchronous and returns `{ err, pduObj }` / `{ err, buffer }`. PDU codec is synchronous and returns `{ err, pduObj }` / `{ err, buffer }`.
- **`pduObj.isResp()` is now the standalone `isResp(pduObj)`.** - **`pduObj.isResp()` is now the standalone `isResp(pduObj)`**, and `pduObj.cmdStatus` is `undefined`
for a status code the library does not know, with the raw number in `pduObj.cmdStatusId`.
- **`defs.filters` is gone.** It was declared on every command and TLV but never invoked, so it did - **`defs.filters` is gone.** It was declared on every command and TLV but never invoked, so it did
nothing. SMPP time formatting, the one part worth keeping, is exported as `smppTime`. nothing. SMPP time formatting, the one part worth keeping, is exported as `smppTime`.
- **The `error` event is `sessionError`** (and `serverError` on the server handle). - **The `error` event is `sessionError`** (and `serverError` on the server handle).
@@ -196,6 +275,12 @@ have worked around any of these, remove the workaround:
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.
- 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` 0x50. 0.4.0 declared 0x00, because the default in its own
command table was never applied. Pass `interfaceVersion: 0x34` to bind as SMPP 3.4.
- `submit_multi` was missing its `sm_length` field, so its `short_message` never round-tripped.
The corrected framing is cross-checked against [node-smpp](https://github.com/farhadi/node-smpp), an
independent implementation, in both directions and over a live session.
## Development ## Development
@@ -203,8 +288,9 @@ Everything runs in the container; nothing is installed on the host.
```bash ```bash
docker compose run --rm node npm install docker compose run --rm node npm install
docker compose run --rm node npm test # lint, typecheck and tests docker compose run --rm node npm test # lint, typecheck and tests
docker compose run --rm node npm run build docker compose run --rm node npm run build
docker compose run --rm node npm run test:compiled # what CI runs on older Node versions
``` ```
Tests are TypeScript and run directly under Node's type stripping, so there is no build step in the Tests are TypeScript and run directly under Node's type stripping, so there is no build step in the
+2 -2
View File
@@ -44,8 +44,8 @@
"build": "tsc --project tsconfig.build.json", "build": "tsc --project tsconfig.build.json",
"lint": "eslint . && tsc --noEmit", "lint": "eslint . && tsc --noEmit",
"prepack": "npm run build", "prepack": "npm run build",
"test": "npm run lint && node --test 'test/**/*.test.ts'", "test": "npm run lint && node --test test/*.test.ts",
"test:compiled": "tsc --project tsconfig.test.json && node --test dist-test/test/" "test:compiled": "tsc --project tsconfig.test.json && node --test dist-test/test/*.test.js"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
+53 -193
View File
@@ -3,226 +3,86 @@
Remaining work for the `@larvit/smpp` 1.0.0 rewrite. Read [AGENTS.md](AGENTS.md) first — the hard Remaining work for the `@larvit/smpp` 1.0.0 rewrite. Read [AGENTS.md](AGENTS.md) first — the hard
rules there constrain every item below. rules there constrain every item below.
## How to resume ## Status
The rewrite is **feature complete and green**: 136 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 ```bash
docker compose run --rm node npm install docker compose run --rm node npm install
docker compose run --rm node npm test docker compose run --rm node npm test
``` ```
Work top to bottom. Each task states what "done" means. Tests come before implementation: write the
failing test for the behaviour, then implement until it passes. The 0.4.0 implementation is on the
`master` branch of this repository and is the reference for protocol behaviour — read it, do not
copy its structure.
## The agreed API ## The agreed API
Settled with the maintainer before implementation started. Do not change any of it without asking; Settled with the maintainer before implementation. Do not change any of it without asking. The
it is the contract the tasks below implement. public surface is documented in [README.md](README.md); this is the short form.
```ts ```ts
import { client, server, consts, defs, errors, objToPdu, pduToObj } from '@larvit/smpp'; import { client, server } from '@larvit/smpp';
// --- client ---------------------------------------------------------------
const { err, session } = await client({
addrNpi: 0, // optional, bind field
addrTon: 0, // optional, bind field
addressRange: '', // optional, bind field
bindType: 'transceiver', // 'transceiver' | 'transmitter' | 'receiver'
enquireLinkInterval: 20000,
host: 'localhost',
interfaceVersion: 0x50, // optional, bind field
log, // @larvit/log LogInt, silent by default
maxOutstanding: 10, // in-flight requests before sends queue
password: 'pass',
port: 2775,
reconnect: { maxDelay: 30000, minDelay: 1000 }, // optional, opt-in
responseTimeout: 30000,
signal, // optional AbortSignal
systemType: '', // optional, bind field
tls: false, // boolean or tls.ConnectionOptions
username: 'user',
});
const { err, pduObjs, smsIds } = await session.sendSms({
dlr: false,
encoding: 'UCS2', // optional, overrides auto-detection
flash: false,
from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1
message: 'Hello world',
scheduleDeliveryTime: undefined, // optional
to: '46709771337',
validityPeriod: undefined, // optional
}, { signal }); // optional per-call AbortSignal
const { err, session } = await client({ host, password, port, username });
const { err, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to });
await session.unbind(); await session.unbind();
await session.close();
// --- server ---------------------------------------------------------------
const { err, server: smpp } = await server({
authenticate: async ({ password, systemId }) => {
if (systemId !== 'foo' || password !== 'bar') return false;
return { userData: { userId: 123 } }; // attached to session.userData
},
idleTimeout: 40000,
log,
maxReassembly: 1000,
port: 2775,
reassemblyTimeout: 300000,
signal,
tls: false,
});
smpp.port; // resolved port, useful when 0 was requested
await smpp.close();
const { err, server: smpp } = await server({ authenticate, port });
smpp.on('session', session => { smpp.on('session', session => {
session.on('sms', async sms => { session.on('sms', async sms => {
const { err } = await sms.sendResp(); // defaults to ESME_ROK await sms.sendResp();
if (sms.dlr) await sms.sendDlr('DELIVERED'); // defaults to DELIVERED if (sms.dlr) await sms.sendDlr('DELIVERED');
}); });
}); });
await smpp.close();
``` ```
### Events Rules the API follows:
| Emitter | Event | Payload |
| --- | --- | --- |
| server | `session` | `Session` |
| server | `serverError` | `Error` |
| session | `sms` | `Sms` (live handle: `sendResp()`, `sendDlr()`) |
| session | `dlr` | one per segment — `{ doneDate?, errorCode?, smsId, statusId, statusMsg }` |
| session | `messageDlr` | merged once all segments of a message are accounted for — adds `segments` and the worst status across them |
| session | `sessionError` | `Error` |
| session | `close` | — |
| session | `reconnected` | — (only when `reconnect` is configured) |
| session | `data` | `Buffer` |
| session | `incomingPdu` | `Buffer` |
| session | `incomingPduObj` | `PduObject` |
### Rules the API follows
- **Never throws.** Everything fallible resolves to `{ err?, … }`. See AGENTS.md rule 1. - **Never throws.** Everything fallible resolves to `{ err?, … }`. See AGENTS.md rule 1.
- **Named exports only.** No default export. `defs` stays as a grouped export alongside the - **Named exports only**, no default export. `defs` is exported as a group alongside the individual
individual tables (`cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, and the `*ById` maps). tables.
- **`utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`, `encodeMessage`,
`objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `smppTime`, `splitMessage`.
- **`defs.filters` is gone** — it never did anything. `smppTime` replaces the one useful part.
- **The PDU codec is synchronous** and returns `{ err?, pduObj? }` / `{ err?, buffer? }`. - **The PDU codec is synchronous** and returns `{ err?, pduObj? }` / `{ err?, buffer? }`.
- **Low-level surface stays public**, including `session.sock`, `session.send()` and - **Low-level surface stays public**, including `session.sock`, `session.send()` and
`session.sendReturn()` — that is how the other 29 commands are reached. `session.sendReturn()`.
- `pduObj.isResp()` is now the standalone export `isResp(pduObj)`; PDU objects are pure data.
## Tasks ## Done
### 1. Definition tables — `src/defs/` | | Covered by |
| --- | --- |
| Definition tables: constants, errors, encodings, wire types, TLVs, commands | `test/encodings.test.ts`, `test/types.test.ts`, `test/commands.test.ts` |
| Message helpers: splitting, bit counting, SMPP dates and times | `test/message.test.ts` |
| PDU codec: parse, build, respond, per-command typing, bounds checks | `test/pdu.test.ts` |
| 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` |
| 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/` |
All done, with tests in `test/encodings.test.ts`, `test/types.test.ts` and `test/commands.test.ts`. Every defect listed in the AGENTS.md table has a regression test naming the behaviour.
- [x] `constants.ts``consts` + `constsById`. ## Before publishing 1.0.0
- [x] `errors.ts` — all `ESME_*` codes plus `errorsById`, `isErrorName`, `errorNameById`.
- [x] `types.ts` — wire types. `read` is bounds-checked and reports `bytesRead`, so no caller has to
re-derive a length that could disagree with what was written. `size` and `write` validate their
input and return results.
- [x] `encodings.ts` — GSM 03.38, LATIN1, UCS2, FLASH, `detect` and `encodingByDataCoding`.
`iconv-lite` is gone; Buffer does it natively.
- [x] `tlvs.ts` — 67 TLV definitions, `tlvsById`, and the two aliases.
- [x] `commands.ts` — all 33 commands, wire-ordered, plus `cmdsById` and the `PduParams<C>` /
`PduParamsInput<C>` per-command types.
- [x] `filters.ts`**not ported.** `defs.filters` was declared on commands and TLVs but never
invoked anywhere in 0.4.0. The one piece that is genuinely needed, SMPP time formatting, is
task 2's `smppTime`.
### 2. Message helpers — `src/message.ts` - [ ] **Decide the `interface_version` default.** Binds now declare 0x50, which is what 0.4.0's own
command table intended but never applied — it actually sent 0x00. 0x34 (SMPP 3.4) is the more
conservative choice and is what most SMSCs implement. Flagged to the maintainer; the README
documents 0x50 and the `interfaceVersion` option overrides it either way.
- [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which
`.github/workflows/release.yaml` needs.
- [ ] Tag `v1.0.0` to publish.
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp`. Maintainer's call to run it; not
something CI should do.
- [ ] Decide what happens to `master`: this branch is an orphan, so merging it is a deliberate act.
Done, tested in `test/message.test.ts`. Segments are 153 GSM / 67 UCS2 characters, `smppDate` is ## Worth doing, not blocking
UTC and one-based, `decodeMessage` goes through `encodingByDataCoding`, and `splitMessage` takes the
concatenation reference as an argument rather than owning a module-global counter.
- [x] `bitCount`, `encodeMessage`, `decodeMessage`, `smppDate`, `splitMessage`, `smppTime`. - [ ] **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
### 3. PDU codec — `src/pdu.ts` but risks duplicate delivery, so it needs a decision before it is built.
- [ ] **TLS is untested.** The code path is right (`tls.connect` / `tls.createServer`, options
Done, tested in `test/pdu.test.ts` — which includes the two byte-for-byte comparisons against 0.4.0's passed through) but no test exercises a handshake. Needs a self-signed certificate fixture.
output and the real captured SMSC PDUs from its suite. - [ ] **`submit_multi` and the broadcast commands** encode and decode, but nothing exercises them
end to end. The interop suite is the natural place.
- [x] `pduToObj`, `objToPdu`, `pduReturn`, `isResp`, `isCommand`. - [ ] **Move to TypeScript 7** once `typescript-eslint` supports it; `renovate.json` pins TypeScript
- [x] Trailing-NULL retry kept, now an explicit second parse rather than a side effect of a length below 6.1 for exactly that reason.
function. - [ ] **Coverage reporting.** `node --test --experimental-test-coverage` works today; nothing
- [x] `cmdLength` guarded by `maxPduLength` (1 MiB) before anything is allocated. publishes the numbers.
- [x] `objToPdu` narrows `params` to the named command. `pduToObj` returns loosely-typed params —
the command is only known at runtime — and `isCommand(pduObj, 'submit_sm')` narrows them.
The encoder measures each field, allocates exactly that, and writes into it, so a `size`/`write`
disagreement surfaces as an error instead of a corrupt PDU. That class of bug is what the trailing
NULL defect was.
### 4. Session — `src/session.ts`
- [ ] Framing off the socket. Replace 0.4.0's `Buffer.concat` per chunk with an accumulating reader —
concatenating the whole queue on every `data` event is quadratic.
- [ ] Sequence numbers, wrapping at 2147483646.
- [ ] `send()` with `responseTimeout`, `maxOutstanding` windowing and a queue, and `AbortSignal`.
Listeners must be removed on every exit path — timeout, abort and close included.
- [ ] `sendReturn()`, `unbind()`, `close()`.
- [ ] `submit_sm` handling, including long-SMS reassembly with `maxReassembly` and a real
`reassemblyTimeout` timer.
- [ ] `deliver_sm` handling: prefer the TLVs, fall back to parsing the receipt text
(`id:… sub:001 dlvrd:1 submit date:… done date:… stat:DELIVRD err:0 text:…`).
- [ ] Per-segment `dlr` plus merged `messageDlr`.
- [ ] `enquire_link` and `unbind` handling.
### 5. Sms handle — `src/sms.ts`
- [ ] `sendResp(status?)` and `sendDlr(status?)` returning result DTOs.
- [ ] Generated `message_id` values are UUID v7.
- [ ] **Fix:** `sendDlr` emits the 7-character spec status (`UNDELIV`, not `UNDELIVERABLE`).
### 6. Client and server — `src/client.ts`, `src/server.ts`
- [ ] `client()` — bind by `bindType`, all bind fields optional with 0.4.0's defaults.
- [ ] **Fix:** real TLS via `tls.connect()` / `tls.createServer()`, accepting an options object.
- [ ] Opt-in reconnect with exponential backoff between `minDelay` and `maxDelay`, re-binding and
emitting `reconnected`. Decide and document what happens to in-flight sends across a reconnect.
- [ ] `server()` — resolves once when listening, exposes `port`, `close()` and the `session` event.
- [ ] `authenticate` hook; `userData` attached to the session.
- [ ] `idleTimeout` drops silent peers.
### 7. Public surface — `src/index.ts`
- [ ] Named exports only, matching "The agreed API" above exactly.
### 8. Tests
Port the 0.4.0 suite (`test/01_encodings.js``test/04_session.js` on `master`) and extend it. Every
defect in the AGENTS.md table needs a test that fails against 0.4.0 behaviour.
- [ ] Encodings, wire types, PDU round-trips, return PDUs, message sizing and splitting.
- [ ] Sessions: bind, auth success and failure, simple SMS, long SMS, DLRs, and the Kannel capture
with the large UDH from `test/04_session.js`.
- [ ] **Interop:** add the reference `smpp` package (farhadi/node-smpp) as a dev dependency and assert
both directions — our encoder against its parser, its encoder against our parser. This is how
the wire-format fixes are validated; the maintainer asked specifically for proof that the
corrected framing is right rather than differently wrong.
- [ ] Reassembly bounds, response timeouts, abort, and the send window.
### 9. CI and release
- [ ] `.github/workflows/test.yaml` — lint, typecheck and test on Node 18, 20, 22 and 24. The 18/20
legs need the tests compiled first, since type stripping needs Node 22.18+.
- [ ] `.github/workflows/release.yaml``npm publish --provenance` on a `v*` tag.
- [ ] `renovate.json`.
- [ ] Delete nothing from `master`; this branch simply does not carry `.travis.yml`.
### 10. Release chores
- [ ] Fill in README usage docs as each part lands — right now the README documents the target API,
and it must not claim anything that is not implemented and tested.
- [ ] `npm deprecate larvitsmpp` pointing at `@larvit/smpp` once 1.0.0 is published. Maintainer's
call to run it; not something CI should do.
## Open questions
None outstanding. Everything above was settled with the maintainer; anything genuinely new that comes
up during implementation should be asked rather than assumed.