Type each known TLV's value by its tag, on read and on write
Test / lint (pull_request) Successful in 32s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 30s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 38s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 4s

This commit is contained in:
2026-09-27 15:12:09 +02:00
parent 42b1239f3d
commit 75d4794522
12 changed files with 131 additions and 53 deletions
+73 -21
View File
@@ -79,15 +79,18 @@ const specs = tlvSpecs({
its_session_info: { id: 0x1383, tag: 'its_session_info', type: tlv.buffer },
});
export type TlvName = keyof typeof specs;
type Specs = typeof specs;
export const tlvs: Record<TlvName, TlvDefinition> & Record<string, TlvDefinition> = {
...specs,
// Alternate spellings; the definition behind each keeps its canonical name.
export type TlvName = keyof Specs;
// Alternate spellings; the definition behind each keeps its canonical name.
const alternates = {
alert_on_msg_delivery: specs.alert_on_message_delivery,
failed_broadcast_area_identifier: specs.broadcast_area_identifier,
};
export const tlvs: Record<TlvName, TlvDefinition> & Record<string, TlvDefinition> = { ...specs, ...alternates };
export const tlvsById: Record<number, TlvDefinition> = {};
for (const definition of Object.values<TlvDefinition>(specs)) {
@@ -97,11 +100,28 @@ for (const definition of Object.values<TlvDefinition>(specs)) {
/** Fallback for tags this table does not know: keep the raw octets. */
export const tlvDefault: WireType<Buffer> = tlv.buffer;
export type Tlv = {
tagId: number;
tagName: string | undefined;
tagValue: TlvValue;
};
type Repeated<K extends TlvName, V> = Specs[K] extends { multiple: true } ? V[] : V;
type WireValue<K extends TlvName> = Specs[K]['type']['default'];
export type TlvReadValue<K extends TlvName> = Repeated<K, WireValue<K>>;
/** A lone text field also takes a number, and a lone octet field text, which goes out as latin1. */
export type TlvWriteValue<K extends TlvName> = Specs[K] extends { multiple: true } ? TlvReadValue<K>
: WireValue<K> extends number ? number
: WireValue<K> extends string ? number | string
: Buffer | number | string;
type OneTlv<K extends TlvName> = { tagId: number; tagName: K; tagValue: TlvReadValue<K> };
export type Tlv<K extends TlvName = TlvName> = { [N in K]: OneTlv<N> }[K];
/** A tag the TLV table does not define, keyed by its decimal id. */
export type UnknownTlv = { tagId: number; tagName: undefined; tagValue: Buffer };
type KnownTlvs = { [K in TlvName]?: OneTlv<K> };
export type Tlvs = KnownTlvs & Record<`${number}`, UnknownTlv>;
export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
@@ -109,6 +129,14 @@ export type TlvInput = {
tagValue: TlvValue;
};
type Alternate = keyof typeof alternates;
type Canonical<K extends Alternate | TlvName> = K extends Alternate ? (typeof alternates)[K]['tag'] : K;
export type TlvInputs = {
[K in Alternate | TlvName]?: { tagId?: number | undefined; tagValue: TlvWriteValue<Canonical<K>> };
} & Record<string, TlvInput>;
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
const tagId = input.tagId ?? tlvs[name]?.id;
@@ -198,10 +226,38 @@ function readTlv(pdu: Buffer, offset: number): Result<{ octets: number; occurren
return { occurrence: { definition, tagId, value: read.value }, octets: 4 + tagLength };
}
function isTlvName(name: string): name is TlvName {
return Object.hasOwn(specs, name);
}
function readsAs(definition: TlvDefinition, value: unknown): boolean {
const kind = definition.type.default;
const fits = (one: unknown): boolean => typeof one === typeof kind && Buffer.isBuffer(one) === Buffer.isBuffer(kind);
return definition.multiple === true ? Array.isArray(value) && value.every(fits) : fits(value);
}
function isTlvShape(tlv: unknown): tlv is { tagId: number; tagName: unknown; tagValue: unknown } {
return typeof tlv === 'object' && tlv !== null && 'tagId' in tlv && typeof tlv.tagId === 'number'
&& 'tagName' in tlv && 'tagValue' in tlv;
}
function isTlv(key: string, tlv: unknown): boolean {
if (!isTlvShape(tlv)) return false;
if (tlv.tagName === undefined) return /^\d+$/.test(key) && Buffer.isBuffer(tlv.tagValue);
return tlv.tagName === key && isTlvName(key) && readsAs(specs[key], tlv.tagValue);
}
/** Every entry keyed by its tag name, or an unknown tag by its decimal id, holding what its table type reads. */
export function isTlvs(record: Record<string, unknown>): record is Tlvs {
return Object.entries(record).every(([key, tlv]) => isTlv(key, tlv));
}
/** Keyed by tag name, a repeatable tag listing every occurrence in wire order and any other keeping its last. */
function keyedTlvs(occurrences: Occurrence[]): Record<string, Tlv> {
function keyedTlvs(occurrences: Occurrence[]): Result<{ tlvs: Tlvs }> {
const repeated = new Map<string, { tagId: number; values: Occurrence['value'][] }>();
const tlvs: Record<string, Tlv> = {};
const tlvs: Record<string, unknown> = {};
for (const { definition, tagId, value } of occurrences) {
const key = definition?.tag ?? tagId.toString();
@@ -217,19 +273,13 @@ function keyedTlvs(occurrences: Occurrence[]): Record<string, Tlv> {
}
for (const [key, { tagId, values }] of repeated) {
const buffers = values.filter(value => Buffer.isBuffer(value));
tlvs[key] = {
tagId,
tagName: key,
tagValue: buffers.length === values.length ? buffers : values.filter(value => typeof value === 'number'),
};
tlvs[key] = { tagId, tagName: key, tagValue: values };
}
return tlvs;
return isTlvs(tlvs) ? { tlvs } : { err: new Error('A TLV did not read as its table type') };
}
export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Record<string, Tlv> }> {
export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Tlvs }> {
const occurrences: Occurrence[] = [];
let offset = start;
@@ -242,5 +292,7 @@ export function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number;
offset += read.octets;
}
return { offset, tlvs: keyedTlvs(occurrences) };
const keyed = keyedTlvs(occurrences);
return keyed.err ? { err: keyed.err } : { offset, tlvs: keyed.tlvs };
}
+2 -2
View File
@@ -64,10 +64,10 @@ export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts'
export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts';
export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts';
export type { ErrorName } from './defs/errors.ts';
export type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
export type { PduObject, PduObjectInput, TlvInput, TlvInputs } from './pdu.ts';
export type { PduHeader } from './pdu-refusal.ts';
export type { SplitOptions } from './message.ts';
export type { Tlv, TlvDefinition, TlvName } from './defs/tlvs.ts';
export type { Tlv, TlvDefinition, TlvName, TlvReadValue, TlvWriteValue, Tlvs, UnknownTlv } from './defs/tlvs.ts';
export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './defs/types.ts';
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
+6 -6
View File
@@ -3,7 +3,7 @@ import type { ErrorName } from './defs/errors.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduHeader } from './pdu-refusal.ts';
import type { Result, VoidResult } from './result.ts';
import type { Tlv, TlvInput } from './defs/tlvs.ts';
import type { TlvInput, TlvInputs, Tlvs } from './defs/tlvs.ts';
import { PduRefusedError, framingRefusal } from './pdu-refusal.ts';
import { cmds, commandNameById, respNameFor } from './defs/commands.ts';
import { hasUdh } from './defs/constants.ts';
@@ -23,7 +23,7 @@ export type PduObjectInput<C extends CommandName = CommandName> = {
cmdStatus?: ErrorName;
params?: PduParamsInput<C>;
seqNr?: number;
tlvs?: Record<string, TlvInput> | undefined;
tlvs?: TlvInputs | undefined;
};
/**
@@ -43,10 +43,10 @@ export type PduObject = {
* the peer put in `message_payload` is not here; `messageOctets()` is what reads either.
*/
shortMessageOctets: Buffer | undefined;
tlvs: Record<string, Tlv>;
tlvs: Tlvs;
};
export type { TlvInput };
export type { TlvInput, TlvInputs };
const respBit = 0x80000000;
@@ -293,7 +293,7 @@ function readOptionalParams(
pdu: Buffer,
start: number,
afterShortMessage: boolean,
): Result<{ tlvs: Record<string, Tlv> }> {
): Result<{ tlvs: Tlvs }> {
const plain = parseTlvs(pdu, start);
if (!plain.err && plain.offset === pdu.length) return { tlvs: plain.tlvs };
@@ -418,7 +418,7 @@ export function pduReturn(
pdu: Buffer | PduObject,
status: ErrorName = 'ESME_ROK',
params: Record<string, ParamValue> = {},
tlvs?: Record<string, TlvInput>,
tlvs?: TlvInputs,
): Result<{ buffer: Buffer }> {
if (Buffer.isBuffer(pdu)) {
const parsed = pduToObj(pdu);
+3 -3
View File
@@ -1,12 +1,12 @@
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts';
import type { Tlv } from './defs/tlvs.ts';
import { detachedTlv, tlvOctets } from './defs/types.ts';
import { isTlvs } from './defs/tlvs.ts';
/** Wire reads hand back views, so retaining one PDU would pin the whole chunk it arrived in. */
export function detach(pduObj: PduObject): PduObject {
const params: Record<string, ParamValue> = {};
const tlvs: Record<string, Tlv> = {};
const tlvs: Record<string, unknown> = {};
for (const [name, value] of Object.entries(pduObj.params)) {
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
@@ -21,7 +21,7 @@ export function detach(pduObj: PduObject): PduObject {
? params.short_message
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
return { ...pduObj, params, shortMessageOctets: octets, tlvs: isTlvs(tlvs) ? tlvs : pduObj.tlvs };
}
// Measured heap beyond the octets, so a PDU of empty fields or empty TLVs is not free.
+2 -2
View File
@@ -1,5 +1,5 @@
import type { CloseOptions, OnRequest } from './session-options.ts';
import type { PduObject, TlvInput } from './pdu.ts';
import type { PduObject, TlvInputs } 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';
@@ -162,7 +162,7 @@ async function authenticate(
}
/** An ESME reads a missing sc_interface_version as this SMSC having none. */
function bindRespTlvs(session: Session, options: ServerOptions): Record<string, TlvInput> | undefined {
function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | undefined {
if (!session.acceptsOptionalParams()) return undefined;
return {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { ErrorName } from './defs/errors.ts';
import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts';
import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
@@ -175,7 +175,7 @@ export class Session extends EventEmitter<SessionEvents> {
pdu: PduObject,
status: ErrorName = 'ESME_ROK',
params: Record<string, ParamValue> = {},
tlvs?: Record<string, TlvInput>,
tlvs?: TlvInputs,
): Promise<VoidResult> {
return Promise.resolve(
this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr),
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ErrorName } from './defs/errors.ts';
import type { MessageState } from './defs/constants.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
import { UnansweredError } from './unanswered-error.ts';
@@ -179,7 +179,7 @@ function receiptText(sms: Sms, smsId: string, status: MessageState): string {
].join(' ');
}
function receiptTlvs(smsId: string, status: MessageState): Record<string, TlvInput> {
function receiptTlvs(smsId: string, status: MessageState): TlvInputs {
return {
message_state: { tagValue: consts.MESSAGE_STATE[status] },
receipted_message_id: { tagValue: smsId },