Validate the wire format against node-smpp and add CI

This commit is contained in:
2026-08-25 16:52:33 +02:00
parent fd2f98464f
commit 2e4699944b
10 changed files with 486 additions and 5 deletions
+26
View File
@@ -0,0 +1,26 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
cache: npm
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+36
View File
@@ -0,0 +1,36 @@
name: Test
on:
push:
branches: ['**']
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
cache: npm
node-version: 24
- run: npm ci
- run: npm run lint
- run: npm run build
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# The floor in package.json engines, every LTS above it, and current.
node: ['18', '20', '22', '24']
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
cache: npm
node-version: ${{ matrix.node }}
- run: npm ci
# Compiled rather than type-stripped: Node 18 and 20 cannot run TypeScript directly.
- run: npm run test:compiled
+1
View File
@@ -1,3 +1,4 @@
.claude .claude
dist dist
dist-test
node_modules node_modules
+1 -1
View File
@@ -2,7 +2,7 @@ import eslint from '@eslint/js';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist/'] }, { ignores: ['dist/', 'dist-test/'] },
eslint.configs.recommended, eslint.configs.recommended,
tseslint.configs.strictTypeChecked, tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked, tseslint.configs.stylisticTypeChecked,
+63 -3
View File
@@ -1,11 +1,11 @@
{ {
"name": "larvitsmpp", "name": "@larvit/smpp",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "larvitsmpp", "name": "@larvit/smpp",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -15,11 +15,12 @@
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@types/node": "22.20.1", "@types/node": "22.20.1",
"eslint": "10.9.1", "eslint": "10.9.1",
"smpp": "0.6.0-rc.4",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.68.0" "typescript-eslint": "8.68.0"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
@@ -826,6 +827,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/findhit-proxywrap": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/findhit-proxywrap/-/findhit-proxywrap-0.3.13.tgz",
"integrity": "sha512-gI1KV7yCuMHtveiWbQUJZheNOukScz+15MTtrH/MK5RJ77JjYeJ1DegUfkBnlPvDx5NhZESl48zE/MwpWZ1LXQ==",
"dev": true,
"license": "BSD",
"dependencies": {
"lodash": "^4.17.21"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -860,6 +874,23 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -971,6 +1002,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -1104,6 +1142,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -1140,6 +1185,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/smpp": {
"version": "0.6.0-rc.4",
"resolved": "https://registry.npmjs.org/smpp/-/smpp-0.6.0-rc.4.tgz",
"integrity": "sha512-WDa0XBRQkkJJPcKRtoC9C0cnzhopFIK9/zFcWBOy3sD4xZr3i/Dt3yX+XRparKzpp9QkqRr2/8EYt8JNGxu85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"findhit-proxywrap": "^0.3.12",
"iconv-lite": "0.x",
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+3 -1
View File
@@ -44,12 +44,14 @@
"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/"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@types/node": "22.20.1", "@types/node": "22.20.1",
"eslint": "10.9.1", "eslint": "10.9.1",
"smpp": "0.6.0-rc.4",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.68.0" "typescript-eslint": "8.68.0"
}, },
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"packageRules": [
{
"description": "typescript-eslint peer-requires TypeScript <6.1, so TS 7 has to wait for it.",
"allowedVersions": "<6.1.0",
"matchPackageNames": ["typescript"]
}
]
}
+284
View File
@@ -0,0 +1,284 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import reference from 'smpp';
import type { ReferenceSession } from 'smpp';
import type { Sms } from '../src/sms.ts';
import { client } from '../src/client.ts';
import { concatInfo } from '../src/udh.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts';
import { server } from '../src/server.ts';
import { splitMessage } from '../src/message.ts';
/**
* Cross-checks against farhadi/node-smpp, an independent SMPP implementation. This is what backs
* the claim that the corrected framing is right rather than differently wrong.
*/
function ourBuffer(...args: Parameters<typeof objToPdu>): Buffer {
const { buffer, err } = objToPdu(...args);
assert.equal(err, undefined);
assert.ok(buffer);
return buffer;
}
function referenceParse(buffer: Buffer) {
const parsed = reference.PDU.fromBuffer(buffer);
assert.ok(parsed, 'the reference implementation could not parse our PDU');
return parsed;
}
describe('our encoder against the reference parser', () => {
test('a GSM submit_sm', () => {
const parsed = referenceParse(ourBuffer({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
short_message: 'Hello world',
source_addr: '46701113311',
},
seqNr: 12,
}));
assert.equal(parsed.command, 'submit_sm');
assert.equal(parsed.sequence_number, 12);
assert.equal(parsed.source_addr, '46701113311');
assert.equal(parsed.destination_addr, '46709771337');
assert.equal(String(parsed.short_message?.message), 'Hello world');
});
test('a UCS2 submit_sm, including a character whose low byte is zero', () => {
const parsed = referenceParse(ourBuffer({
cmdName: 'submit_sm',
params: {
destination_addr: '46709771337',
short_message: 'hej 一',
source_addr: '46701113311',
},
seqNr: 3,
}));
assert.equal(parsed.data_coding, 8);
assert.equal(String(parsed.short_message?.message), 'hej 一');
});
test('a bind_transceiver', () => {
const parsed = referenceParse(ourBuffer({
cmdName: 'bind_transceiver',
params: {
interface_version: 0x50,
password: 'bar',
system_id: 'foo',
system_type: 'smpp',
},
seqNr: 1,
}));
assert.equal(parsed.command, 'bind_transceiver');
assert.equal(parsed.system_id, 'foo');
assert.equal(parsed.password, 'bar');
assert.equal(parsed.interface_version, 0x50);
});
test('a deliver_sm carrying delivery receipt TLVs', () => {
const parsed = referenceParse(ourBuffer({
cmdName: 'deliver_sm',
params: {
destination_addr: '46701113311',
esm_class: 4,
short_message: 'id:abc123 sub:001 dlvrd:001 stat:DELIVRD err:000 text:',
source_addr: '46709771337',
},
seqNr: 77,
tlvs: {
message_state: { tagId: 0x0427, tagValue: 2 },
receipted_message_id: { tagId: 0x001E, tagValue: 'abc123' },
},
}));
assert.equal(parsed.command, 'deliver_sm');
assert.equal(parsed.message_state, 2);
assert.equal(parsed.receipted_message_id, 'abc123');
});
// The segment sizes and UDH are the wire change most worth an independent opinion.
test('every segment of a long message, with its concatenation header', () => {
const message = 'a'.repeat(400);
const segments = splitMessage(message, { reference: 0x2A });
assert.equal(segments.length, 3);
let rebuilt = '';
for (const [index, segment] of segments.entries()) {
const parsed = referenceParse(ourBuffer({
cmdName: 'submit_sm',
params: {
data_coding: 0,
destination_addr: '46709771337',
esm_class: 0x40,
short_message: segment,
sm_length: segment.length,
source_addr: '46701113311',
},
seqNr: index + 1,
}));
const body = String(parsed.short_message?.message);
assert.deepEqual(concatInfo(segment), { part: index + 1, reference: 0x2A, total: 3 });
// Both sides must agree on where the 6-octet UDH ends and the text begins.
assert.equal(body.length, segment.length - 6);
rebuilt += body;
}
assert.equal(rebuilt, message);
});
});
describe('the reference encoder against our parser', () => {
test('a submit_sm', () => {
const built = new reference.PDU('submit_sm', {
destination_addr: '46709771337',
sequence_number: 5,
short_message: 'Hello from the reference',
source_addr: '46701113311',
}).toBuffer();
const { err, pduObj } = pduToObj(built);
assert.equal(err, undefined);
assert.ok(pduObj);
assert.equal(pduObj.cmdName, 'submit_sm');
assert.equal(pduObj.seqNr, 5);
assert.equal(pduObj.params.source_addr, '46701113311');
assert.equal(pduObj.params.short_message, 'Hello from the reference');
});
test('a UCS2 submit_sm', () => {
const built = new reference.PDU('submit_sm', {
destination_addr: '46709771337',
sequence_number: 6,
short_message: 'تست 一',
source_addr: '46701113311',
}).toBuffer();
const { err, pduObj } = pduToObj(built);
assert.equal(err, undefined);
assert.ok(pduObj);
assert.equal(pduObj.params.short_message, 'تست 一');
});
test('a submit_sm_resp', () => {
const built = new reference.PDU('submit_sm_resp', {
message_id: 'ref-123',
sequence_number: 7,
}).toBuffer();
const { err, pduObj } = pduToObj(built);
assert.equal(err, undefined);
assert.ok(pduObj);
assert.equal(pduObj.cmdName, 'submit_sm_resp');
assert.equal(pduObj.params.message_id, 'ref-123');
});
test('a deliver_sm with TLVs', () => {
const built = new reference.PDU('deliver_sm', {
destination_addr: '46701113311',
esm_class: 4,
message_state: 2,
receipted_message_id: 'ref-456',
sequence_number: 8,
short_message: 'id:ref-456 stat:DELIVRD err:000',
source_addr: '46709771337',
}).toBuffer();
const { err, pduObj } = pduToObj(built);
assert.equal(err, undefined);
assert.ok(pduObj);
assert.equal(pduObj.tlvs.receipted_message_id?.tagValue, 'ref-456');
assert.equal(pduObj.tlvs.message_state?.tagValue, 2);
});
});
describe('a live session against the reference implementation', () => {
test('our client binds to a reference server and delivers an SMS', async () => {
const received: { from: string; message: string }[] = [];
const refServer = reference.createServer({}, (session: ReferenceSession) => {
session.on('bind_transceiver', pdu => {
session.bind_transceiver_resp({
sequence_number: pdu.sequence_number,
system_id: 'ref',
});
});
session.on('submit_sm', pdu => {
received.push({
from: String(pdu.source_addr),
message: String(pdu.short_message?.message),
});
session.submit_sm_resp({
message_id: 'ref-id',
sequence_number: pdu.sequence_number,
});
});
});
await new Promise<void>(resolve => { refServer.listen(0, () => { resolve(); }); });
const port = refServer.address()?.port ?? 0;
const { err, session } = await client({ port });
assert.equal(err, undefined);
assert.ok(session);
const sent = await session.sendSms({
from: 'MyBrand',
message: 'interop check',
to: '46709771337',
});
assert.equal(sent.err, undefined);
assert.deepEqual(sent.smsIds, ['ref-id']);
assert.deepEqual(received, [{ from: 'MyBrand', message: 'interop check' }]);
session.close();
await new Promise<void>(resolve => { refServer.close(() => { resolve(); }); });
});
test('a reference client binds to our server and delivers an SMS', async () => {
const { err: serverErr, server: smpp } = await server({ port: 0 });
assert.equal(serverErr, undefined);
assert.ok(smpp);
const incoming = new Promise<Sms>(resolve => {
smpp.on('session', session => session.on('sms', resolve));
});
const refSession = reference.connect({
url: `smpp://localhost:${String(smpp.port)}`,
});
await new Promise<void>(resolve => {
refSession.bind_transceiver({ password: 'bar', system_id: 'foo' }, () => { resolve(); });
});
refSession.submit_sm({
destination_addr: '46709771337',
short_message: 'from the reference client',
source_addr: '46701113311',
});
const sms = await incoming;
assert.equal(sms.from, '46701113311');
assert.equal(sms.message, 'from the reference client');
await sms.sendResp();
refSession.close();
await smpp.close();
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Just enough of the reference implementation (farhadi/node-smpp) to cross-check the wire format.
* It ships no types of its own and is a dev dependency only.
*/
declare module 'smpp' {
type ReferencePdu = {
command: string;
command_status: number;
data_coding?: number;
destination_addr?: string;
esm_class?: number;
interface_version?: number;
message_id?: string;
message_state?: number;
password?: string;
receipted_message_id?: string;
sequence_number: number;
short_message?: { message: Buffer | string };
source_addr?: string;
system_id?: string;
toBuffer: () => Buffer;
};
type ReferenceSession = {
bind_transceiver: (options: Record<string, unknown>, cb: (pdu: ReferencePdu) => void) => void;
bind_transceiver_resp: (options: Record<string, unknown>) => void;
close: () => void;
on: (event: string, listener: (pdu: ReferencePdu) => void) => void;
submit_sm: (options: Record<string, unknown>, cb?: (pdu: ReferencePdu) => void) => void;
submit_sm_resp: (options: Record<string, unknown>) => void;
};
type ReferenceServer = {
address: () => { port: number } | null;
close: (cb?: () => void) => void;
listen: (port: number, cb?: () => void) => void;
};
const smpp: {
PDU: {
new (command: string, fields?: Record<string, unknown>): ReferencePdu;
fromBuffer: (buffer: Buffer) => ReferencePdu | false;
};
connect: (options: Record<string, unknown>, cb?: () => void) => ReferenceSession;
createServer: (
options: Record<string, unknown>,
handler: (session: ReferenceSession) => void,
) => ReferenceServer;
};
export default smpp;
export type { ReferencePdu, ReferenceServer, ReferenceSession };
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist-test"
},
"include": ["src", "test"]
}