Scaffold ESM TypeScript rewrite as @larvit/smpp
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = tab
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{yaml,yml}]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
@@ -0,0 +1,3 @@
|
||||
.claude
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,107 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for LLM agents working in this repository. Human-facing documentation lives in
|
||||
[README.md](README.md); the remaining work is tracked in [todo.md](todo.md).
|
||||
|
||||
## What this is
|
||||
|
||||
A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 1.0.0. The branch
|
||||
started from an orphan commit — no history from 0.4.0 is carried over. The 0.4.0 source is still
|
||||
readable on the `master` branch of the same repository and is the reference for protocol behaviour,
|
||||
not for structure or style.
|
||||
|
||||
The library's value is its very small API. Do not grow the public surface without being asked.
|
||||
|
||||
## Hard rules
|
||||
|
||||
These are not preferences. Breaking one is a defect.
|
||||
|
||||
1. **Nothing throws.** Every fallible function returns (or resolves to) a DTO carrying an optional
|
||||
`err`. No `throw`, no rejected promises, no exceptions as control flow. Node APIs that throw are
|
||||
wrapped at the boundary and converted into a result. Programmer errors (bad arguments) are
|
||||
results too.
|
||||
2. **Log messages are static strings.** Every dynamic value goes into `@larvit/log` metadata. Never
|
||||
interpolate, never concatenate.
|
||||
- GOOD: `log.debug('sendSms() - splitting message', { parts: msgs.length, to });`
|
||||
- BANNED: `log.debug('sendSms() - splitting into ' + msgs.length + ' parts');`
|
||||
3. **No `error` event.** Node makes an unhandled `error` event throw, which would break rule 1.
|
||||
Sessions emit `sessionError`, servers emit `serverError`.
|
||||
4. **No casts, no non-null assertions.** `as`, `as unknown as` and `!` are all banned. Parse untyped
|
||||
input once through a type guard at the boundary; everything past it is typed.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts Public surface. Named exports only, no default export.
|
||||
client.ts client() -> { err, session }
|
||||
server.ts server() -> { err, server }, server owns the listener + close()
|
||||
session.ts Session: framing, sequence numbers, the send window, events
|
||||
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
|
||||
message.ts Encoding detection, splitting, bit counting, SMPP date formatting
|
||||
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
|
||||
defs/
|
||||
commands.ts The 33 commands, their ids and ordered parameter lists
|
||||
constants.ts consts + constsById (TON, NPI, ENCODING, MESSAGE_STATE, …)
|
||||
encodings.ts GSM 03.38, LATIN1, UCS2 and detection
|
||||
errors.ts errors + errorsById (ESME_*)
|
||||
filters.ts Per-field encode/decode hooks (time, message, callback_num, …)
|
||||
tlvs.ts TLV definitions, tlvsById
|
||||
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
|
||||
```
|
||||
|
||||
Dependency direction is one way: `defs` knows nothing above it, `pdu` uses `defs`, `session` uses
|
||||
`pdu`, and `client`/`server` use `session`. Nothing reaches back up.
|
||||
|
||||
**Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are
|
||||
written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering
|
||||
convention applies everywhere else, but here it corrupts every PDU.
|
||||
|
||||
## Toolchain
|
||||
|
||||
Run everything through the container; never invoke node or npm on the host.
|
||||
|
||||
```bash
|
||||
docker compose run --rm node npm install
|
||||
docker compose run --rm node npm test
|
||||
docker compose run --rm node npm run build
|
||||
```
|
||||
|
||||
- Tests are `.ts` and run directly under Node's type stripping — no build step in the dev loop.
|
||||
- Source imports use `.ts` extensions; `rewriteRelativeImportExtensions` emits `.js` into `dist`.
|
||||
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
|
||||
objects plus union types.
|
||||
- The published floor is Node 18, but the dev container runs Node 24 (type stripping needs it). CI
|
||||
compiles the tests and runs them on 18/20/22/24, so the floor is verified rather than asserted.
|
||||
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
|
||||
TypeScript 7 once that constraint lifts.
|
||||
|
||||
## Defects found in 0.4.0
|
||||
|
||||
Confirmed by reading the 0.4.0 source. The rewrite fixes all of them; each needs a regression test
|
||||
naming the behaviour, and the wire-affecting ones are cross-checked against a reference
|
||||
implementation (see todo.md).
|
||||
|
||||
| Defect | 0.4.0 behaviour |
|
||||
| --- | --- |
|
||||
| LATIN1 never decodes | `decodeMsg` loops `consts.ENCODING` without breaking, so `data_coding` 0x03 lands on the alias `ISO_8859_1`, which has no decoder, and silently falls back to ASCII |
|
||||
| Oversized segments | `splitMsg` emits 152 GSM chars + 6-byte UDH = 158 octets, over the 140-octet limit; UCS2 gets 66 chars where 67 fit |
|
||||
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
|
||||
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
|
||||
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
|
||||
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
|
||||
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
|
||||
| `tls: true` is not TLS | Constructs a bare `new tls.Socket()` with no handshake instead of `tls.connect()` |
|
||||
| Alphanumeric sender TON | `sendSms` hardcodes `source_addr_ton` to 1 (international) even for alphanumeric senders, which require TON 5 |
|
||||
| Text-only DLRs refused | `deliver_sm` without both `message_state` and `receipted_message_id` TLVs is rejected with `ESME_RINVTLVSTREAM`, so Kannel-style receipts are unusable |
|
||||
| Unbounded reassembly | Incomplete long-SMS groups are capped by nothing and swept only when other traffic arrives, after 24 hours |
|
||||
| Dead DLR aggregation | `longSmsDlrs` is allocated to merge per-segment receipts and then never used |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Hard tabs. Alphabetical ordering for keys, imports and lists unless order is logic-significant
|
||||
(see the wire-order note above).
|
||||
- Comments are the exception, not the default — see the root `CLAUDE.md` rules. Do not write file
|
||||
preambles or restate what the code says.
|
||||
- Test data uses real randomised UUID v7 values, never `aaaa-0000` placeholders.
|
||||
- `message_id` values the library generates are UUID v7.
|
||||
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2018 Larv IT AB
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# @larvit/smpp
|
||||
|
||||
A simplified implementation of the SMPP protocol, in TypeScript. ESM only, types included.
|
||||
|
||||
Successor to [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The API is the same shape
|
||||
it has always been — connect, send an SMS, listen for delivery reports — with callbacks replaced by
|
||||
promises and the rough edges taken off.
|
||||
|
||||
> **Status: not released.** This branch is a rewrite in progress and nothing here is published yet.
|
||||
> The API below is the agreed design and is being implemented against it; see
|
||||
> [todo.md](todo.md) for what is done and what is not. Use `larvitsmpp` 0.4.0 until 1.0.0 ships.
|
||||
|
||||
## Requirements
|
||||
|
||||
Node 18 or later.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @larvit/smpp
|
||||
```
|
||||
|
||||
## Client
|
||||
|
||||
The simplest possible client — connects to localhost:2775 with no credentials and sends a message:
|
||||
|
||||
```javascript
|
||||
import { client } from '@larvit/smpp';
|
||||
|
||||
const { err, session } = await client();
|
||||
if (err) throw err;
|
||||
|
||||
await session.sendSms({
|
||||
from: '46701113311',
|
||||
message: 'Hello world',
|
||||
to: '46709771337',
|
||||
});
|
||||
|
||||
await session.unbind();
|
||||
```
|
||||
|
||||
With connection parameters, a delivery report and logging:
|
||||
|
||||
```javascript
|
||||
import { Log } from '@larvit/log';
|
||||
import { client } from '@larvit/smpp';
|
||||
|
||||
const log = new Log('debug');
|
||||
|
||||
const { err, session } = await client({
|
||||
host: 'smpp.somewhere.com',
|
||||
log,
|
||||
password: 'bar',
|
||||
port: 2775,
|
||||
username: 'foo',
|
||||
});
|
||||
if (err) throw err;
|
||||
|
||||
session.on('dlr', dlr => {
|
||||
// dlr.smsId, dlr.statusMsg, dlr.statusId
|
||||
});
|
||||
|
||||
const { err: sendErr, smsIds } = await session.sendSms({
|
||||
dlr: true,
|
||||
from: '46701113311',
|
||||
message: '«baff»',
|
||||
to: '46709771337',
|
||||
});
|
||||
```
|
||||
|
||||
## Server
|
||||
|
||||
The simplest possible server — no authentication, listening on port 2775:
|
||||
|
||||
```javascript
|
||||
import { server } from '@larvit/smpp';
|
||||
|
||||
const { err, server: smpp } = await server();
|
||||
if (err) throw err;
|
||||
|
||||
smpp.on('session', session => {
|
||||
session.on('sms', async sms => {
|
||||
// sms.from, sms.to, sms.message, sms.dlr
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
With authentication and delivery reports:
|
||||
|
||||
```javascript
|
||||
import { server } from '@larvit/smpp';
|
||||
|
||||
const { err, server: smpp } = await server({
|
||||
// Replace with your own auth. Returning an object attaches it to session.userData.
|
||||
authenticate: async ({ password, systemId }) => {
|
||||
if (systemId !== 'foo' || password !== 'bar') return false;
|
||||
|
||||
return { userData: { userId: 123 } };
|
||||
},
|
||||
});
|
||||
if (err) throw err;
|
||||
|
||||
smpp.on('session', session => {
|
||||
session.on('sms', async sms => {
|
||||
// Responding is part of the protocol, not optional.
|
||||
// Defaults to ESME_ROK; see the SMPP spec for the other status codes.
|
||||
await sms.sendResp();
|
||||
|
||||
if (sms.dlr) {
|
||||
await sms.sendDlr(); // same as sms.sendDlr('DELIVERED')
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await smpp.close();
|
||||
```
|
||||
|
||||
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
||||
`ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`.
|
||||
|
||||
## Errors
|
||||
|
||||
Nothing in this library throws. Every fallible call returns a result carrying an optional `err`, so
|
||||
failures are handled in one place instead of two:
|
||||
|
||||
```javascript
|
||||
const { err, session } = await client({ host: 'smpp.somewhere.com' });
|
||||
if (err) return;
|
||||
|
||||
const { err: sendErr, smsIds } = await session.sendSms({ from, message, to });
|
||||
```
|
||||
|
||||
Runtime failures on a live connection arrive as `sessionError` and `serverError` events. They are
|
||||
deliberately not called `error`: Node turns an unhandled `error` event into a thrown exception, which
|
||||
is exactly what this library promises not to do.
|
||||
|
||||
## Sessions
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Fires when |
|
||||
| --- | --- |
|
||||
| `sms` | An SMS arrives. Carries `sendResp()` and `sendDlr()`. |
|
||||
| `dlr` | A delivery report arrives, one per segment. |
|
||||
| `messageDlr` | Every segment of a multipart message has been reported on. |
|
||||
| `close` | The socket closed. |
|
||||
| `reconnected` | The client re-bound after a drop (only with `reconnect` configured). |
|
||||
| `sessionError` | Something failed on a live session. |
|
||||
| `data` | Raw bytes arrived on the socket. |
|
||||
| `incomingPdu` | A complete PDU arrived, as a buffer. |
|
||||
| `incomingPduObj` | The same PDU, parsed into an object. |
|
||||
|
||||
### Methods
|
||||
|
||||
`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.
|
||||
|
||||
## Migrating from larvitsmpp 0.4.0
|
||||
|
||||
- **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
|
||||
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
|
||||
`session` event. It no longer calls your callback once per incoming connection.
|
||||
- **`checkuserpass` is now `authenticate`**, takes `{ password, systemId }` and returns `false` or
|
||||
`{ userData }`.
|
||||
- **Renamed options:** `enqLinkTiming` → `enquireLinkInterval`, server `timeout` → `idleTimeout`.
|
||||
- **`larvitsmpp.utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`,
|
||||
`encodeMessage`, `objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `splitMessage`. The PDU codec is
|
||||
synchronous and returns `{ err, pduObj }` / `{ err, buffer }`.
|
||||
- **`pduObj.isResp()` is now the standalone `isResp(pduObj)`.**
|
||||
- **The `error` event is `sessionError`** (and `serverError` on the server handle).
|
||||
- **`log`** takes a [`@larvit/log`](https://www.npmjs.com/package/@larvit/log) instance instead of a
|
||||
`larvitutils` one, and is silent by default.
|
||||
|
||||
### Behaviour that changed on the wire
|
||||
|
||||
0.4.0 had a number of protocol defects. Fixing them changes the bytes it puts on the wire, so if you
|
||||
have worked around any of these, remove the workaround:
|
||||
|
||||
- Multipart segments were 158 octets, over the 140-octet limit. They are now correctly sized.
|
||||
- LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message.
|
||||
- Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec
|
||||
defines the 7-character `UNDELIV`.
|
||||
- `flash: true` discarded UCS2, mangling flash messages containing non-GSM characters.
|
||||
- The multipart reference counter was shared by every session in the process.
|
||||
- `tls: true` never performed a handshake, so the connection was not actually encrypted.
|
||||
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
|
||||
- Delivery receipts carrying only the standard receipt text, with no TLVs — what Kannel and several
|
||||
other SMSCs send — were rejected outright. They are now parsed.
|
||||
|
||||
## Development
|
||||
|
||||
Everything runs in the container; nothing is installed on the host.
|
||||
|
||||
```bash
|
||||
docker compose run --rm node npm install
|
||||
docker compose run --rm node npm test # lint, typecheck and tests
|
||||
docker compose run --rm node npm run build
|
||||
```
|
||||
|
||||
Tests are TypeScript and run directly under Node's type stripping, so there is no build step in the
|
||||
development loop. CI additionally compiles and runs them on Node 18, 20, 22 and 24 to verify the
|
||||
supported range.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
node:
|
||||
image: node:24.18.0-bookworm-slim
|
||||
init: true
|
||||
user: "1000:1000"
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
NPM_CONFIG_CACHE: /tmp/npm-cache
|
||||
@@ -0,0 +1,26 @@
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/'] },
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'no-console': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['eslint.config.js'],
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
},
|
||||
);
|
||||
Generated
+1281
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@larvit/smpp",
|
||||
"version": "1.0.0",
|
||||
"description": "Simplified SMPP implementation",
|
||||
"keywords": [
|
||||
"esm",
|
||||
"pdu",
|
||||
"sms",
|
||||
"smpp",
|
||||
"typescript"
|
||||
],
|
||||
"homepage": "https://github.com/larvit/larvitsmpp",
|
||||
"bugs": {
|
||||
"url": "https://github.com/larvit/larvitsmpp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/larvit/larvitsmpp.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Mikael 'Lilleman' Göransson",
|
||||
"email": "lilleman@larvit.se",
|
||||
"url": "http://github.com/larvit/larvitsmpp"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "eslint . && tsc --noEmit",
|
||||
"prepack": "npm run build",
|
||||
"test": "npm run lint && node --test 'test/**/*.test.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "22.20.1",
|
||||
"eslint": "10.9.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export const consts = {
|
||||
BROADCAST_AREA_FORMAT: {
|
||||
ALIAS: 0x00,
|
||||
ELLIPSOID_ARC: 0x01,
|
||||
NAME: 0x00,
|
||||
POLYGON: 0x02,
|
||||
},
|
||||
BROADCAST_FREQUENCY_INTERVAL: {
|
||||
DAYS: 0x0B,
|
||||
HOURS: 0x0A,
|
||||
MAX_POSSIBLE: 0x00,
|
||||
MINUTES: 0x09,
|
||||
MONTHS: 0x0D,
|
||||
SECONDS: 0x08,
|
||||
WEEKS: 0x0C,
|
||||
YEARS: 0x0E,
|
||||
},
|
||||
ENCODING: {
|
||||
ASCII: 0x01,
|
||||
BINARY: 0x04,
|
||||
CYRILLIC: 0x06,
|
||||
EXTENDED_KANJI_JIS: 0x0D,
|
||||
FLASH: 0x10,
|
||||
HEBREW: 0x07,
|
||||
IA5: 0x01,
|
||||
ISO_2022_JP: 0x0A,
|
||||
ISO_8859_1: 0x03,
|
||||
ISO_8859_5: 0x06,
|
||||
ISO_8859_8: 0x07,
|
||||
JIS: 0x05,
|
||||
KS_C_5601: 0x0E,
|
||||
LATIN1: 0x03,
|
||||
PICTOGRAM: 0x09,
|
||||
UCS2: 0x08,
|
||||
X_0208_1990: 0x05,
|
||||
X_0212_1990: 0x0D,
|
||||
},
|
||||
ESM_CLASS: {
|
||||
CONVERSATION_ABORT: 0x18,
|
||||
DATAGRAM: 0x01,
|
||||
DELIVERY_ACKNOWLEDGEMENT: 0x08,
|
||||
FORWARD: 0x02,
|
||||
INTERMEDIATE_DELIVERY: 0x20,
|
||||
MC_DELIVERY_RECEIPT: 0x04,
|
||||
SET_REPLY_PATH: 0x80,
|
||||
STORE_FORWARD: 0x03,
|
||||
UDH_INDICATOR: 0x40,
|
||||
USER_ACKNOWLEDGEMENT: 0x10,
|
||||
},
|
||||
MESSAGE_STATE: {
|
||||
ACCEPTED: 6,
|
||||
DELETED: 4,
|
||||
DELIVERED: 2,
|
||||
ENROUTE: 1,
|
||||
EXPIRED: 3,
|
||||
REJECTED: 8,
|
||||
SCHEDULED: 0,
|
||||
SKIPPED: 9,
|
||||
UNDELIVERABLE: 5,
|
||||
UNKNOWN: 7,
|
||||
},
|
||||
NETWORK: {
|
||||
CDMA: 0x03,
|
||||
GENERIC: 0x00,
|
||||
GSM: 0x01,
|
||||
TDMA: 0x02,
|
||||
},
|
||||
NPI: {
|
||||
DATA: 0x03,
|
||||
ERMES: 0x0A,
|
||||
INTERNET: 0x0E,
|
||||
IP: 0x0E,
|
||||
ISDN: 0x01,
|
||||
LAND_MOBILE: 0x06,
|
||||
NATIONAL: 0x08,
|
||||
PRIVATE: 0x09,
|
||||
TELEX: 0x04,
|
||||
UNKNOWN: 0x00,
|
||||
WAP: 0x12,
|
||||
},
|
||||
REGISTERED_DELIVERY: {
|
||||
DELIVERY_ACKNOWLEDGEMENT: 0x04,
|
||||
FAILURE: 0x02,
|
||||
FINAL: 0x01,
|
||||
INTERMEDIATE: 0x10,
|
||||
SUCCESS: 0x03,
|
||||
USER_ACKNOWLEDGEMENT: 0x08,
|
||||
},
|
||||
TON: {
|
||||
ABBREVIATED: 0x06,
|
||||
ALPHANUMERIC: 0x05,
|
||||
INTERNATIONAL: 0x01,
|
||||
NATIONAL: 0x02,
|
||||
NETWORK_SPECIFIC: 0x03,
|
||||
SUBSCRIBER_NUMBER: 0x04,
|
||||
UNKNOWN: 0x00,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ConstGroup = keyof typeof consts;
|
||||
export type MessageState = keyof typeof consts.MESSAGE_STATE;
|
||||
|
||||
// Aliased values (NPI.IP === NPI.INTERNET === 0x0E) resolve to whichever name sorts last.
|
||||
export const constsById: Record<string, Record<number, string>> = {};
|
||||
|
||||
for (const [groupName, group] of Object.entries(consts)) {
|
||||
const byId: Record<number, string> = {};
|
||||
|
||||
for (const [name, value] of Object.entries(group)) {
|
||||
byId[value] = name;
|
||||
}
|
||||
|
||||
constsById[groupName] = byId;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Either a failure carrying err, or a success carrying T. Both branches declare every key, so
|
||||
* callers can destructure once and let `if (err)` narrow what is left.
|
||||
*/
|
||||
export type Result<T> =
|
||||
| ({ err: Error } & Partial<Record<keyof T, undefined>>)
|
||||
| ({ err?: undefined } & T);
|
||||
|
||||
export type VoidResult = { err?: Error };
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Result } from '../src/result.ts';
|
||||
|
||||
function parse(input: string): Result<{ value: number }> {
|
||||
const value = Number(input);
|
||||
|
||||
if (Number.isNaN(value)) return { err: new Error('not a number') };
|
||||
|
||||
return { value };
|
||||
}
|
||||
|
||||
test('destructuring a result narrows on err', () => {
|
||||
const { err, value } = parse('42');
|
||||
|
||||
if (err) {
|
||||
assert.fail('should have parsed');
|
||||
}
|
||||
|
||||
// Fails to compile if narrowing does not remove undefined from value.
|
||||
assert.equal(value.toFixed(0), '42');
|
||||
});
|
||||
|
||||
test('a failed result carries err and nothing else', () => {
|
||||
const { err, value } = parse('nope');
|
||||
|
||||
assert.ok(err instanceof Error);
|
||||
assert.equal(value, undefined);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
# todo.md
|
||||
|
||||
Remaining work for the `@larvit/smpp` 1.0.0 rewrite. Read [AGENTS.md](AGENTS.md) first — the hard
|
||||
rules there constrain every item below.
|
||||
|
||||
## How to resume
|
||||
|
||||
```bash
|
||||
docker compose run --rm node npm install
|
||||
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
|
||||
|
||||
Settled with the maintainer before implementation started. Do not change any of it without asking;
|
||||
it is the contract the tasks below implement.
|
||||
|
||||
```ts
|
||||
import { client, server, consts, defs, errors, objToPdu, pduToObj } 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
|
||||
|
||||
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();
|
||||
|
||||
smpp.on('session', session => {
|
||||
session.on('sms', async sms => {
|
||||
const { err } = await sms.sendResp(); // defaults to ESME_ROK
|
||||
if (sms.dlr) await sms.sendDlr('DELIVERED'); // defaults to DELIVERED
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
| 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.
|
||||
- **Named exports only.** No default export. `defs` stays as a grouped export alongside the
|
||||
individual tables (`cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, and the `*ById` maps).
|
||||
- **`utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`, `encodeMessage`,
|
||||
`objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `splitMessage`.
|
||||
- **The PDU codec is synchronous** and returns `{ err?, pduObj? }` / `{ err?, buffer? }`.
|
||||
- **Low-level surface stays public**, including `session.sock`, `session.send()` and
|
||||
`session.sendReturn()` — that is how the other 29 commands are reached.
|
||||
- `pduObj.isResp()` is now the standalone export `isResp(pduObj)`; PDU objects are pure data.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Definition tables — `src/defs/`
|
||||
|
||||
- [x] `constants.ts` — `consts` + `constsById`.
|
||||
- [ ] `errors.ts` — all `ESME_*` codes plus `errorsById`. Port from 0.4.0 `lib/defs.js`; note that
|
||||
0.4.0 has a typo, `ESME_RINVBCASTCHANIND = 0x011` (three digits), which should be `0x0111`.
|
||||
Verify every code against the SMPP 3.4/5.0 spec while porting.
|
||||
- [ ] `types.ts` — wire types `int8`, `int16`, `int32`, `string`, `cstring`, `buffer`,
|
||||
`dest_address_array`, `unsuccess_sme_array`, and the `tlv` variants. Each is
|
||||
`{ default, read(buffer, offset, length?), size(value), write(value, buffer, offset) }`.
|
||||
`read` must be bounds-checked and return a result rather than throwing.
|
||||
- [ ] `encodings.ts` — GSM 03.38 (`ASCII`), `LATIN1`, `UCS2`, `FLASH` (alias of ASCII) and `detect`.
|
||||
Drop `iconv-lite`: LATIN1 is `Buffer.from(str, 'latin1')`, UCS2 is `Buffer.from(str,
|
||||
'utf16le').swap16()` (copy before swapping, and reject odd-length input on decode).
|
||||
- [ ] `filters.ts` — `time`, `message`, `billing_identification`, `broadcast_area_identifier`,
|
||||
`broadcast_content_type`, `broadcast_frequency_interval`, `callback_num`, `callback_num_atag`.
|
||||
- [ ] `tlvs.ts` — the 67 TLV definitions plus `tlvsById` and the two aliases
|
||||
(`alert_on_msg_delivery`, `failed_broadcast_area_identifier`).
|
||||
- [ ] `commands.ts` — all 33 commands with ids and **wire-ordered** parameter lists, plus `cmdsById`.
|
||||
|
||||
### 2. Message helpers — `src/message.ts`
|
||||
|
||||
- [ ] `bitCount(msg, encoding?)`, `encodeMessage`, `decodeMessage`, `smppDate`, `splitMessage`.
|
||||
- [ ] **Fix:** segments are 134 GSM characters or 67 UCS2 characters, so that segment + 6-byte UDH
|
||||
is exactly 140 octets. 0.4.0 produces 152/66.
|
||||
- [ ] **Fix:** `smppDate` must add 1 to `getMonth()` and zero-pad correctly.
|
||||
- [ ] **Fix:** LATIN1 must actually decode — resolve `data_coding` to a concrete encoding, not to
|
||||
whichever alias happens to sort last.
|
||||
- [ ] The concatenation reference counter is per session, not module-global. `splitMessage` therefore
|
||||
takes the reference as an argument instead of owning a counter.
|
||||
|
||||
### 3. PDU codec — `src/pdu.ts`
|
||||
|
||||
- [ ] `pduToObj(buffer)` → `{ err?, pduObj? }`, `objToPdu(obj)` → `{ err?, buffer? }`,
|
||||
`pduReturn(pdu, status?, params?, tlvs?)` → `{ err?, buffer? }`, `isResp(pduObj)`.
|
||||
- [ ] Keep the trailing-NULL-octet retry for `short_message` that 0.4.0 has — real peers send it.
|
||||
- [ ] Guard `cmdLength` against a maximum before allocating, so a hostile peer cannot ask for a 4 GiB
|
||||
buffer. 0.4.0 has no such guard.
|
||||
- [ ] Per-command typed params: `pduToObj` returns a union discriminated on `cmdName`, and
|
||||
`objToPdu` narrows `params` to the named command's fields.
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2023"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"noEmit": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "ES2023",
|
||||
"types": ["node"],
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["eslint.config.ts", "src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user