Let the session own its bind state, written by bound() alone #42

Merged
lilleman merged 11 commits from session-bound into main 2026-09-28 04:37:29 +02:00
8 changed files with 51 additions and 19 deletions
Showing only changes of commit 2ac72fb3f4 - Show all commits
+2 -2
View File
@@ -76,8 +76,8 @@
- `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it. - `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it.
- `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is - `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is
removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it
accepted or had accepted with `session.bound(bindType, declaredVersion)`; an assignment to either accepted or had accepted with `session.bound(bindType, declaredVersion)`, which returns `err` for a
field now throws a `TypeError`. bind type or version it cannot record; an assignment to either field now throws a `TypeError`.
## 0.5.0 ## 0.5.0
+3
View File
@@ -36,6 +36,9 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back
under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from
`tlvs` too. `tlvs` too.
- **`session.loggedIn` is gone**: read `session.boundAs !== undefined`. `boundAs` and
`peerInterfaceVersion` are read-only; a session you construct yourself records a bind with
`session.bound()`.
- **The `error` event is `sessionError`**, and `serverError` on the server handle. - **The `error` event is `sessionError`**, and `serverError` on the server handle.
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of
a `larvitutils` one, and is silent by default: [README](README.md#logging). a `larvitutils` one, and is silent by default: [README](README.md#logging).
+6 -4
View File
@@ -423,10 +423,11 @@ const { err, pduObj } = await session.send({
a `send()` you build is passed through as written, so check it yourself. a `send()` you build is passed through as written, so check it yourself.
- `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind. - `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind.
- `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction). - `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction).
- Both hold through a reconnect's gap: the loop binds again as before. - `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the
next `bound()`.
- `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever - `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever
end accepted it. A `declaredVersion` that is not a number is none declared. Both fields are end accepted it, on every link it binds. `declaredVersion` is 0-255, or `undefined` where the peer
read-only. declared none; anything else, or an unknown `bindType`, returns `err` and records nothing.
## Receiving in depth ## Receiving in depth
@@ -544,7 +545,8 @@ if (err) throw err;
the link. Guard on the command name, as above, and a failing hook costs only its own request. the link. Guard on the command name, as above, and a failing hook costs only its own request.
- A `Session` you construct yourself takes the same hook as a session option, and that is where a - A `Session` you construct yourself takes the same hook as a session option, and that is where a
peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call
`session.bound(bindType, pduObj.params.interface_version)` before answering it. `session.bound(bindType, pduObj.params.interface_version)` before answering it, and refuse the bind
with `ESME_RBINDFAIL` where that returns `err`.
**`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, **`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery `ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
+4 -1
View File
@@ -174,7 +174,10 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) }; return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
} }
session.bound(bindType, sent.pduObj.tlvs.sc_interface_version?.tagValue); const recorded = session.bound(bindType, sent.pduObj.tlvs.sc_interface_version?.tagValue);
if (recorded.err) return recorded;
session.log.info('client - bound', { bindType, systemId }); session.log.info('client - bound', { bindType, systemId });
return {}; return {};
+2 -2
View File
@@ -177,9 +177,9 @@ async function acceptBind(
options: ServerOptions, options: ServerOptions,
identity: Record<string, string>, identity: Record<string, string>,
): Promise<void> { ): Promise<void> {
session.bound(bindType, pduObj.params.interface_version); const recorded = session.bound(bindType, pduObj.params.interface_version);
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options)); await session.sendReturn(pduObj, recorded.err ? 'ESME_RBINDFAIL' : 'ESME_ROK', identity, bindRespTlvs(session, options));
} }
async function onBind( async function onBind(
+23
View File
@@ -121,6 +121,29 @@ export const defaultSystemId = '';
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */ /** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
export const undeclaredInterfaceVersion = 0x00; export const undeclaredInterfaceVersion = 0x00;
export type SessionBind = { as: BindType; peerVersion: number };
const bindTypes: readonly string[] = ['receiver', 'transceiver', 'transmitter'];
function isBindType(value: unknown): value is BindType {
return typeof value === 'string' && bindTypes.includes(value);
}
/** A bind as `Session.bound()` records it: undefined declares no version, which is pre-3.4. */
export function checkedBind(bindType: unknown, declaredVersion: unknown): Result<{ bind: SessionBind }> {
if (!isBindType(bindType)) {
return { err: new Error(`bindType must be ${bindTypes.join(', ')}, got ${namedValue(bindType)}`) };
}
if (declaredVersion === undefined) return { bind: { as: bindType, peerVersion: undeclaredInterfaceVersion } };
if (typeof declaredVersion !== 'number' || !Number.isInteger(declaredVersion) || declaredVersion < 0 || declaredVersion > 0xFF) {
return { err: new Error(`declaredVersion must be 0-255, or undefined where the peer declared none, got ${namedValue(declaredVersion)}`) };
}
return { bind: { as: bindType, peerVersion: declaredVersion } };
}
export const defaults = { export const defaults = {
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
dlrMergeTimeout: 86_400_000, dlrMergeTimeout: 86_400_000,
+10 -9
View File
@@ -4,7 +4,7 @@ import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts'; import type { PduRefusedError } from './pdu-refusal.ts';
import type { BindType, CloseOptions, LinkEnd, OnRequest, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts'; import type { BindType, CloseOptions, LinkEnd, OnRequest, ReconnectOptions, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
@@ -19,7 +19,7 @@ import { ReconnectLoop } from './reconnect-loop.ts';
import { leftOf } from './idle-waiters.ts'; import { leftOf } from './idle-waiters.ts';
import { errorFrom } from './error-from.ts'; import { errorFrom } from './error-from.ts';
import { optionalParamsMinVersion } from './defs/constants.ts'; import { optionalParamsMinVersion } from './defs/constants.ts';
import { bindCarries, bindCommands, defaultSystemId, defaults, undeclaredInterfaceVersion } from './session-options.ts'; import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts';
import { isResp, objToPdu, pduReturn } from './pdu.ts'; import { isResp, objToPdu, pduReturn } from './pdu.ts';
import { refusalAnswer } from './pdu-refusal.ts'; import { refusalAnswer } from './pdu-refusal.ts';
import { guardedLog } from './log.ts'; import { guardedLog } from './log.ts';
@@ -58,7 +58,7 @@ export class Session extends EventEmitter<SessionEvents> {
linkEnd: LinkEnd = 'esme'; linkEnd: LinkEnd = 'esme';
userData: unknown = undefined; userData: unknown = undefined;
private bind: { as: BindType; peerVersion: number } | undefined = undefined; private bind: SessionBind | undefined = undefined;
private readonly concatReference = new ConcatReference(); private readonly concatReference = new ConcatReference();
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
@@ -161,11 +161,13 @@ export class Session extends EventEmitter<SessionEvents> {
return this.bind?.peerVersion; return this.bind?.peerVersion;
} }
/** Records a bind this link accepted or had accepted, until the next one. A version that is not a number is none. */ /** Records a bind this link accepted or had accepted, until the next one. */
bound(bindType: BindType, declaredVersion: unknown): void { bound(bindType: BindType, declaredVersion: unknown): VoidResult {
const peerVersion = typeof declaredVersion === 'number' ? declaredVersion : undeclaredInterfaceVersion; const checked = checkedBind(bindType, declaredVersion);
this.bind = { as: bindType, peerVersion }; if (!checked.err) this.bind = checked.bind;
return checked.err ? { err: checked.err } : {};
} }
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */ /** Whether this session's bind direction carries a command. Consulted by the library's senders. */
@@ -175,8 +177,7 @@ export class Session extends EventEmitter<SessionEvents> {
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */ /** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
acceptsOptionalParams(): boolean { acceptsOptionalParams(): boolean {
return this.peerInterfaceVersion === undefined return this.peerInterfaceVersion === undefined || this.peerInterfaceVersion >= optionalParamsMinVersion;
|| this.peerInterfaceVersion >= optionalParamsMinVersion;
} }
/** Sends a request and resolves with the peer's response. */ /** Sends a request and resolves with the peer's response. */
+1 -1
View File
@@ -465,7 +465,7 @@ describe('bind', () => {
// The mistakes an untyped caller makes: the TLV object for its value, a version past int8, a typo. // The mistakes an untyped caller makes: the TLV object for its value, a version past int8, a typo.
for (const [bindType, declared] of [['receiver', { tagValue: 0x34 }], ['receiver', 0x100], ['receiver', 3.4], ['tx', 0x34]]) { for (const [bindType, declared] of [['receiver', { tagValue: 0x34 }], ['receiver', 0x100], ['receiver', 3.4], ['tx', 0x34]]) {
const refused: unknown = Reflect.apply(session.bound, session, [bindType, declared]); const refused: unknown = Reflect.apply(Reflect.get(session, 'bound'), session, [bindType, declared]);
assert.ok(refused && typeof refused === 'object' && 'err' in refused && refused.err instanceof Error); assert.ok(refused && typeof refused === 'object' && 'err' in refused && refused.err instanceof Error);
} }