From 8d245c82c4342739c2a3576f864ba69e0af10967 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 13:42:58 +0200 Subject: [PATCH] Declare the logger contract in the library instead of depending on @larvit/log --- AGENTS.md | 10 ++++++++-- README.md | 38 ++++++++++++++++++++++++++++++++----- package-lock.json | 5 ++--- package.json | 4 +--- src/client.ts | 8 ++++---- src/dlr-merger.ts | 6 +++--- src/incoming-requests.ts | 6 +++--- src/index.ts | 1 + src/link-timers.ts | 4 ++-- src/log.ts | 23 +++++++++++++++++++--- src/pending-requests.ts | 6 +++--- src/reassembly.ts | 6 +++--- src/reconnect-loop.ts | 4 ++-- src/send-sms.ts | 4 ++-- src/server.ts | 14 +++++++------- src/session-options.ts | 4 ++-- src/session.ts | 4 ++-- test/session-extras.test.ts | 13 ++++++++++++- 18 files changed, 110 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 924d929..932a1f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 7b6fb59..6089f6d 100644 --- a/README.md +++ b/README.md @@ -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) => 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 diff --git a/package-lock.json b/package-lock.json index dde675e..e9702c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 37f0c4f..4814172 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/client.ts b/src/client.ts index 64b7e78..15bd359 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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> { +async function connect(options: ClientOptions, log: SmppLog): Promise> { const checked = checkSessionOptions(options); if (checked.err) { diff --git a/src/dlr-merger.ts b/src/dlr-merger.ts index 411bb36..3778faf 100644 --- a/src/dlr-merger.ts +++ b/src/dlr-merger.ts @@ -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; - private readonly log: LogInt; + private readonly log: SmppLog; private readonly max: number; constructor(options: DlrMergerOptions) { diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index 1786ee3..d10abaf 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index 55faad2..42f597b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/link-timers.ts b/src/link-timers.ts index 0fed181..cd6710a 100644 --- a/src/link-timers.ts +++ b/src/link-timers.ts @@ -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; }; diff --git a/src/log.ts b/src/log.ts index 2a2857d..61c65b3 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,5 +1,22 @@ -import type { LogInt } from '@larvit/log'; -import { Log } from '@larvit/log'; +export type LogMetadata = Record; +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, +}; diff --git a/src/pending-requests.ts b/src/pending-requests.ts index 9947198..7949106 100644 --- a/src/pending-requests.ts +++ b/src/pending-requests.ts @@ -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(); private ourSeqNr = 1; - constructor(log: LogInt) { + constructor(log: SmppLog) { this.log = log; } diff --git a/src/reassembly.ts b/src/reassembly.ts index 417f103..9766bc1 100644 --- a/src/reassembly.ts +++ b/src/reassembly.ts @@ -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; - private readonly log: LogInt; + private readonly log: SmppLog; private readonly max: number; private readonly maxOctets: number; private octets = 0; diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts index ce7f454..0c6ec13 100644 --- a/src/reconnect-loop.ts +++ b/src/reconnect-loop.ts @@ -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>; - log: LogInt; + log: SmppLog; maxDelay: number; minDelay: number; /** Brings the owner back up on a freshly opened socket. An err means try again. */ diff --git a/src/send-sms.ts b/src/send-sms.ts index 11e403e..8e3a04d 100644 --- a/src/send-sms.ts +++ b/src/send-sms.ts @@ -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>; }; diff --git a/src/server.ts b/src/server.ts index dcfeca7..50f739b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { readonly sessions = new Set(); - 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; diff --git a/src/session-options.ts b/src/session-options.ts index 46514d0..5ab18a2 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -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; diff --git a/src/session.ts b/src/session.ts index 3b2c6db..bf9457e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { /** 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. */ diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 0a85a71..d80af1c 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -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.