Declare the logger contract in the library instead of depending on @larvit/log

This commit is contained in:
2026-08-27 13:42:58 +02:00
parent ff3667e120
commit 8d245c82c4
18 changed files with 110 additions and 50 deletions
+8 -2
View File
@@ -20,7 +20,7 @@ These are not preferences. Breaking one is a defect.
`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
2. **Log messages are static strings.** Every dynamic value goes into the 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');`
@@ -45,7 +45,7 @@ src/
expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share
incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts silentLog — the default when the application passes none
log.ts SmppLog, the logger contract, and silentLog — the default
message.ts Encoding detection, splitting, bit counting, SMPP date formatting
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
@@ -185,6 +185,12 @@ exactly 140.
`server()`, so an implementation that needs 5.0 throughout can have it. The threshold at or above
which a peer may be sent optional parameters is fixed at 0x34 by the spec and is not the same
constant as the declared version.
- **The logger is a five-method contract this library declares, not a dependency.** `SmppLog` in
`log.ts` is what the code actually calls (`debug`, `error`, `info`, `verbose`, `warn`), so an
application can satisfy it with an object literal and `@larvit/smpp` ships with no runtime
dependencies. `@larvit/log` implements it structurally and stays a devDependency, where
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI
+33 -5
View File
@@ -11,8 +11,7 @@ promises and the rough edges taken off.
## Requirements
Node 18 or later. The only runtime dependency is
[`@larvit/log`](https://www.npmjs.com/package/@larvit/log).
Node 18 or later. No runtime dependencies.
## Install
@@ -85,7 +84,7 @@ Every one is optional.
| `responseTimeout` | `30000` | How long to wait for a response before giving up on it; `0` waits forever. |
| `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. |
| `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop or an idle timeout, with exponential backoff. |
| `log` | silent | A `@larvit/log` instance. |
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods — see [Logging](#logging). |
| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
### Sending
@@ -217,6 +216,35 @@ Runtime failures on a live connection arrive as `sessionError` and `serverError`
deliberately not called `error`: Node turns an unhandled `error` event into a thrown exception, which
is exactly what this library promises not to do.
## Logging
`log` takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods, each
`(msg: string, metadata?: Record<string, boolean | number | string>) => void`. Message strings are
static and every dynamic value goes in the metadata, so entries group by message.
[`@larvit/log`](https://www.npmjs.com/package/@larvit/log) implements it as it stands:
```javascript
import { Log } from '@larvit/log';
import { client } from '@larvit/smpp';
const { err, session } = await client({ log: new Log('debug') });
```
So does an object of your own, forwarding wherever you want it:
```javascript
const log = {
debug: () => undefined,
error: (msg, metadata) => { console.error(msg, metadata); },
info: (msg, metadata) => { console.info(msg, metadata); },
verbose: () => undefined,
warn: (msg, metadata) => { console.warn(msg, metadata); },
};
```
TypeScript users can import `SmppLog` to have the compiler check one.
## Sessions
### Events
@@ -289,8 +317,8 @@ The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `
- **`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`.
- **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.
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of a
`larvitutils` one, and is silent by default. See [Logging](#logging).
### Behaviour that changed on the wire
+2 -3
View File
@@ -8,11 +8,9 @@
"name": "@larvit/smpp",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@larvit/log": "2.3.0"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@larvit/log": "2.3.0",
"@types/node": "22.20.1",
"eslint": "10.9.1",
"smpp": "0.6.0-rc.4",
@@ -221,6 +219,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@larvit/log/-/log-2.3.0.tgz",
"integrity": "sha512-gLRMDWrFnoMGdAr9NXMXRKOUeceU6Qd/CW0c6qDO+DLFT1xWrqO2ubsHyPqPzlZHVUO3w3Bk7kOWo6C2Ykn4ZQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
+1 -3
View File
@@ -49,13 +49,11 @@
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@larvit/log": "2.3.0",
"@types/node": "22.20.1",
"eslint": "10.9.1",
"smpp": "0.6.0-rc.4",
"typescript": "6.0.3",
"typescript-eslint": "8.68.0"
},
"dependencies": {
"@larvit/log": "2.3.0"
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { ConnectionOptions } from 'node:tls';
import type { LogInt } from '@larvit/log';
import type { Result, VoidResult } from './result.ts';
import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net';
import { Session } from './session.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
@@ -20,7 +20,7 @@ export type ClientOptions = {
host?: string;
idleTimeout?: number;
interfaceVersion?: number;
log?: LogInt;
log?: SmppLog;
maxOutstanding?: number;
password?: string;
port?: number;
@@ -134,7 +134,7 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return {};
}
function createSession(options: ClientOptions, log: LogInt, sock: Socket): Session {
function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Session {
const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval;
return new Session({
@@ -157,7 +157,7 @@ function createSession(options: ClientOptions, log: LogInt, sock: Socket): Sessi
});
}
async function connect(options: ClientOptions, log: LogInt): Promise<Result<{ sock: Socket }>> {
async function connect(options: ClientOptions, log: SmppLog): Promise<Result<{ sock: Socket }>> {
const checked = checkSessionOptions(options);
if (checked.err) {
+3 -3
View File
@@ -1,12 +1,12 @@
import type { Dlr } from './dlr.ts';
import type { MessageState } from './defs/constants.ts';
import type { LogInt } from '@larvit/log';
import type { SmppLog } from './log.ts';
import { ExpiringGroups } from './expiring-groups.ts';
export type MessageDlr = Dlr & { segments: Dlr[] };
export type DlrMergerOptions = {
log: LogInt;
log: SmppLog;
max: number;
/** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined;
@@ -50,7 +50,7 @@ function severityOf(dlr: Dlr): number {
export class DlrMerger {
private readonly groups: ExpiringGroups<Group>;
private readonly log: LogInt;
private readonly log: SmppLog;
private readonly max: number;
constructor(options: DlrMergerOptions) {
+3 -3
View File
@@ -1,8 +1,8 @@
import type { DlrMerger } from './dlr-merger.ts';
import type { LogInt } from '@larvit/log';
import type { OnRequest } from './session-options.ts';
import type { PduObject } from './pdu.ts';
import type { Session } from './session.ts';
import type { SmppLog } from './log.ts';
import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults } from './session-options.ts';
import { concatInfo } from './udh.ts';
@@ -13,7 +13,7 @@ import { paramText } from './defs/types.ts';
export type IncomingRequestsOptions = {
dlrMerger: DlrMerger;
log: LogInt;
log: SmppLog;
maxOctets?: number | undefined;
maxReassembly?: number | undefined;
onRequest?: OnRequest | undefined;
@@ -25,7 +25,7 @@ export type IncomingRequestsOptions = {
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
export class IncomingRequests {
private readonly dlrMerger: DlrMerger;
private readonly log: LogInt;
private readonly log: SmppLog;
private readonly onRequest: OnRequest | undefined;
private readonly reassembler: Reassembler;
private readonly session: Session;
+1
View File
@@ -38,6 +38,7 @@ export type { Dlr, Receipt } from './dlr.ts';
export type { SendRespOptions, Sms, SmsInput } from './sms.ts';
export type { ConcatInfo } from './udh.ts';
export type { Result, VoidResult } from './result.ts';
export type { SmppLog } from './log.ts';
export type {
AuthenticateInput,
AuthenticateResult,
+2 -2
View File
@@ -1,11 +1,11 @@
import type { LogInt } from '@larvit/log';
import type { SmppLog } from './log.ts';
export type LinkTimersOptions = {
/** How long between enquire_link probes. Undefined or 0 never probes. */
enquireLinkInterval?: number | undefined;
/** How long a silent peer is kept. Undefined or 0 keeps it forever. */
idleTimeout?: number | undefined;
log: LogInt;
log: SmppLog;
onEnquireLink: () => void;
onIdle: () => void;
};
+20 -3
View File
@@ -1,5 +1,22 @@
import type { LogInt } from '@larvit/log';
import { Log } from '@larvit/log';
export type LogMetadata = Record<string, boolean | number | string>;
export type LogMethod = (msg: string, metadata?: LogMetadata) => void;
/** What the library needs from a logger. A `@larvit/log` instance satisfies it as it stands. */
export type SmppLog = {
debug: LogMethod;
error: LogMethod;
info: LogMethod;
verbose: LogMethod;
warn: LogMethod;
};
const noop: LogMethod = () => undefined;
/** The default: a library that says nothing unless the application asks it to. */
export const silentLog: LogInt = new Log({ logLevel: 'none' });
export const silentLog: SmppLog = {
debug: noop,
error: noop,
info: noop,
verbose: noop,
warn: noop,
};
+3 -3
View File
@@ -1,6 +1,6 @@
import type { LogInt } from '@larvit/log';
import type { PduObject } from './pdu.ts';
import type { Result } from './result.ts';
import type { SmppLog } from './log.ts';
import { maxSeqNr } from './pdu.ts';
export type WaitOptions = {
@@ -14,11 +14,11 @@ type Pending = {
/** Hands out sequence numbers and matches responses to the requests waiting for them. */
export class PendingRequests {
private readonly log: LogInt;
private readonly log: SmppLog;
private readonly pending = new Map<number, Pending>();
private ourSeqNr = 1;
constructor(log: LogInt) {
constructor(log: SmppLog) {
this.log = log;
}
+3 -3
View File
@@ -1,14 +1,14 @@
import type { ConcatInfo } from './udh.ts';
import type { LogInt } from '@larvit/log';
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts';
import type { Tlv } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts';
import { paramText } from './defs/types.ts';
export type ReassemblerOptions = {
log: LogInt;
log: SmppLog;
max: number;
maxOctets?: number | undefined;
/** Injected so expiry can be exercised without a wall clock. */
@@ -97,7 +97,7 @@ export function decodeSegments(pduObjs: PduObject[]): string {
/** Holds the segments of incomplete multipart messages until they are whole, capped and expiring. */
export class Reassembler {
private readonly groups: ExpiringGroups<Group>;
private readonly log: LogInt;
private readonly log: SmppLog;
private readonly max: number;
private readonly maxOctets: number;
private octets = 0;
+2 -2
View File
@@ -1,10 +1,10 @@
import type { LogInt } from '@larvit/log';
import type { Result, VoidResult } from './result.ts';
import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net';
export type ReconnectLoopOptions = {
connect: () => Promise<Result<{ sock: Socket }>>;
log: LogInt;
log: SmppLog;
maxDelay: number;
minDelay: number;
/** Brings the owner back up on a freshly opened socket. An err means try again. */
+2 -2
View File
@@ -1,8 +1,8 @@
import type { EncodingName } from './defs/encodings.ts';
import type { LogInt } from '@larvit/log';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput } from './pdu.ts';
import type { Result } from './result.ts';
import type { SmppLog } from './log.ts';
import { consts } from './defs/constants.ts';
import { detect } from './defs/encodings.ts';
import { paramText } from './defs/types.ts';
@@ -28,7 +28,7 @@ export type SendSmsResult = { err?: Error; pduObjs: PduObject[]; smsIds: string[
/** What sending needs from the session: a concat reference and a way onto the wire. */
export type SendSmsDeps = {
log: LogInt;
log: SmppLog;
reference: number;
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
};
+7 -7
View File
@@ -1,8 +1,8 @@
import type { LogInt } from '@larvit/log';
import type { PduObject, TlvInput } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls';
import type { SmppLog } from './log.ts';
import { EventEmitter } from 'node:events';
import { Session, bindCommands, defaultSystemId } from './session.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
@@ -26,7 +26,7 @@ export type ServerOptions = {
host?: string;
idleTimeout?: number;
interfaceVersion?: number;
log?: LogInt;
log?: SmppLog;
maxOutstanding?: number;
maxOctets?: number;
maxReassembly?: number;
@@ -54,10 +54,10 @@ const defaults = {
export class SmppServer extends EventEmitter<ServerEvents> {
readonly sessions = new Set<Session>();
private readonly log: LogInt;
private readonly log: SmppLog;
private readonly server: NetServer;
constructor(server: NetServer, log: LogInt) {
constructor(server: NetServer, log: SmppLog) {
super();
this.log = log;
this.server = server;
@@ -223,7 +223,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
}
/** Node reports a rejected handshake as `tlsClientError`, which is never an `error` event. */
function createSecureListener(tlsOptions: TlsOptions, log: LogInt): TlsServer {
function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer {
const listener = createTlsServer(tlsOptions);
listener.on('tlsClientError', err => {
@@ -233,7 +233,7 @@ function createSecureListener(tlsOptions: TlsOptions, log: LogInt): TlsServer {
return listener;
}
function checkOptions(options: ServerOptions, log: LogInt, port: number): VoidResult {
function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidResult {
const checked = checkSessionOptions(options);
if (checked.err) return { err: checked.err };
@@ -258,7 +258,7 @@ function checkOptions(options: ServerOptions, log: LogInt, port: number): VoidRe
function createListener(
options: ServerOptions,
log: LogInt,
log: SmppLog,
port: number,
): Result<{ listener: NetServer | TlsServer; useTls: boolean }> {
const useTls = options.tls !== undefined && options.tls !== false;
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Dlr } from './dlr.ts';
import type { LogInt } from '@larvit/log';
import type { MessageDlr } from './dlr-merger.ts';
import type { PduObject } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
import type { SmppLog } from './log.ts';
import type { Sms } from './sms.ts';
import type { Socket } from 'node:net';
@@ -48,7 +48,7 @@ export type ReconnectOptions = {
export type SessionOptions = {
enquireLinkInterval?: number | undefined;
idleTimeout?: number | undefined;
log?: LogInt | undefined;
log?: SmppLog | undefined;
maxOctets?: number | undefined;
maxOutstanding?: number | undefined;
maxReassembly?: number | undefined;
+2 -2
View File
@@ -1,11 +1,11 @@
import type { ErrorName } from './defs/errors.ts';
import type { LogInt } from '@larvit/log';
import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net';
import { DlrMerger } from './dlr-merger.ts';
import { EventEmitter } from 'node:events';
@@ -35,7 +35,7 @@ export { bindCommands, defaultSystemId };
export class Session extends EventEmitter<SessionEvents> {
/** Replaced on reconnect, so hold the session rather than this. */
sock: Socket;
readonly log: LogInt;
readonly log: SmppLog;
loggedIn = false;
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
+12 -1
View File
@@ -8,6 +8,7 @@ import type { MessageDlr } from '../src/session.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Result } from '../src/result.ts';
import type { SendSmsResult } from '../src/send-sms.ts';
import type { SmppLog } from '../src/log.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
import { Reassembler, decodeSegments } from '../src/reassembly.ts';
@@ -357,12 +358,22 @@ describe('reassembly bounds', () => {
// The UDH is peer-controlled, and the default authenticate() accepts every peer.
test('refuses a segment whose concatenation metadata cannot be honoured', () => {
const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 });
const warnings: string[] = [];
const noop = (): void => undefined;
const log: SmppLog = {
debug: noop,
error: noop,
info: noop,
verbose: noop,
warn: msg => { warnings.push(msg); },
};
const reassembler = new Reassembler({ log, max: 10, now: () => 0, timeout: 60_000 });
assert.equal(collect(reassembler, 1, 1, 0), undefined);
assert.equal(collect(reassembler, 2, 0, 3), undefined);
assert.equal(collect(reassembler, 3, 4, 3), undefined);
assert.equal(reassembler.size, 0);
assert.deepEqual(warnings, Array(3).fill('reassembler - dropping a segment the UDH numbers impossibly'));
});
// Parts 1/2 then 2/3 would otherwise complete the stored two-part group, truncating the message.