Read and write every copy of a TLV SMPP lets repeat, and drop the dormant tlvMap
Test / lint (pull_request) Successful in 21s
Test / test (18) (pull_request) Successful in 28s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 29s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 29s
Mirror / push (push) Successful in 6s

This commit is contained in:
2026-09-24 00:37:10 +02:00
parent c5ef4c4703
commit 224acca7d0
12 changed files with 120 additions and 52 deletions
-2
View File
@@ -4,7 +4,6 @@ import { buffer, cstring, dest_address_array, int8, unsuccess_sme_array } from '
type CommandSpec = {
id: number;
params?: Record<string, WireType>;
tlvMap?: Record<string, string>;
};
const bindParams = {
@@ -59,7 +58,6 @@ const specs = {
broadcast_sm_resp: {
id: 0x80000111,
params: { message_id: cstring },
tlvMap: { broadcast_area_identifier: 'failed_broadcast_area_identifier' },
},
cancel_broadcast_sm: {
id: 0x00000113,
+61 -27
View File
@@ -1,16 +1,19 @@
import type { ParamValue, WireType } from './types.ts';
import type { WireType } from './types.ts';
import type { Result } from '../result.ts';
import { tlv } from './types.ts';
/** What one TLV on the wire reads as. */
export type TlvScalar = Buffer | number | string;
export type TlvDefinition = {
id: number;
multiple?: boolean;
tag: string;
type: WireType;
type: WireType<TlvScalar>;
};
/** The constraint keys every definition to its own name, so a `tag` that drifts fails to compile. */
const tlvSpecs = <T extends { [K in keyof T]: { id: number; multiple?: boolean; tag: K; type: WireType } }>(
const tlvSpecs = <T extends { [K in keyof T]: { id: number; multiple?: boolean; tag: K; type: WireType<TlvScalar> } }>(
definitions: T,
): T => definitions;
@@ -98,18 +101,21 @@ for (const definition of Object.values<TlvDefinition>(specs)) {
}
/** Fallback for tags this table does not know: keep the raw octets. */
export const tlvDefault: WireType = tlv.buffer;
export const tlvDefault: WireType<TlvScalar> = tlv.buffer;
/** A list for a tag its definition marks `multiple`, one copy per list entry, and a single value otherwise. */
export type TlvValue = TlvScalar | TlvScalar[];
export type Tlv = {
tagId: number;
tagName: string | undefined;
tagValue: ParamValue;
tagValue: TlvValue;
};
export type TlvInput = {
/** Resolved from the record key; pass it for a tag the TLV table does not define. */
tagId?: number | undefined;
tagValue: ParamValue;
tagValue: TlvValue;
};
export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }> {
@@ -126,6 +132,45 @@ export function tagIdOf(name: string, input: TlvInput): Result<{ tagId: number }
return { tagId };
}
function copiesOf(name: string, definition: TlvDefinition | undefined, value: TlvValue): Result<{ copies: TlvScalar[] }> {
const multiple = definition?.multiple === true;
if (Array.isArray(value) && multiple) return { copies: value };
if (!Array.isArray(value) && !multiple) return { copies: [value] };
return {
err: new Error(multiple
? `TLV "${name}": the tag may repeat, so give its value as a list`
: `TLV "${name}": the tag may not repeat, so give its value alone rather than as a list`),
};
}
function writeTlv(name: string, tagId: number, type: WireType<TlvScalar>, value: TlvScalar): Result<{ chunk: Buffer }> {
const sized = type.size(value);
if (sized.err) {
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
}
if (sized.size > 0xffff) {
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
}
const chunk = Buffer.alloc(sized.size + 4);
chunk.writeUInt16BE(tagId, 0);
chunk.writeUInt16BE(sized.size, 2);
const written = type.write(value, chunk, 4);
if (written.err) {
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
}
return { chunk };
}
/** Each TLV as its four octet header and the value the tag's own wire type writes. */
export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<{ chunks: Buffer[] }> {
const chunks: Buffer[] = [];
@@ -135,29 +180,18 @@ export function writeTlvs(inputs: Record<string, TlvInput> | undefined): Result<
if (tag.err) return { err: tag.err };
const type = tlvsById[tag.tagId]?.type ?? tlvDefault;
const sized = type.size(input.tagValue);
const definition = tlvsById[tag.tagId];
const listed = copiesOf(name, definition, input.tagValue);
if (sized.err) {
return { err: new Error(`TLV "${name}": ${sized.err.message}`) };
if (listed.err) return { err: listed.err };
for (const value of listed.copies) {
const written = writeTlv(name, tag.tagId, definition?.type ?? tlvDefault, value);
if (written.err) return { err: written.err };
chunks.push(written.chunk);
}
if (sized.size > 0xffff) {
return { err: new Error(`TLV "${name}": ${String(sized.size)} octets overflow the two octet length`) };
}
const chunk = Buffer.alloc(sized.size + 4);
chunk.writeUInt16BE(tag.tagId, 0);
chunk.writeUInt16BE(sized.size, 2);
const written = type.write(input.tagValue, chunk, 4);
if (written.err) {
return { err: new Error(`TLV "${name}": ${written.err.message}`) };
}
chunks.push(chunk);
}
return { chunks };
+4 -4
View File
@@ -1,7 +1,7 @@
import type { MessageState } from './defs/constants.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts';
import type { SmsIdFormat } from './sms-id.ts';
import type { TlvValue } from './defs/tlvs.ts';
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
import { encodings } from './defs/encodings.ts';
import { messageOctets } from './message-body.ts';
@@ -150,7 +150,7 @@ const smeMessageTypes: readonly number[] = [
consts.ESM_CLASS.USER_ACKNOWLEDGEMENT,
];
function nonEmptyText(value: ParamValue | undefined): string | undefined {
function nonEmptyText(value: TlvValue | undefined): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
@@ -178,7 +178,7 @@ function receiptBody(pduObj: PduObject): string {
}
function receiptId(
tlvId: ParamValue | undefined,
tlvId: TlvValue | undefined,
receipt: Receipt | undefined,
format: SmsIdFormat,
): string | undefined {
@@ -198,7 +198,7 @@ function isMessageState(name: string | undefined): name is MessageState {
/** The state TLV wins where it names a state we know; an unnameable one leaves the body to say. */
function receiptStatus(
tlvState: ParamValue | undefined,
tlvState: TlvValue | undefined,
receipt: Receipt | undefined,
): { statusId: number; statusMsg: MessageState | undefined } {
const scraped = receiptStates[receipt?.stat?.toUpperCase() ?? ''];
+1 -1
View File
@@ -67,7 +67,7 @@ export type { ErrorName } from './defs/errors.ts';
export type { PduObject, PduObjectInput, TlvInput } 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, TlvScalar, TlvValue } from './defs/tlvs.ts';
export type { DestAddress, ParamValue, UnsuccessSme, WireType } from './defs/types.ts';
/** The spec tables, grouped the way `larvitsmpp.defs` was in 0.4.0. */
+11 -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 { Tlv, TlvDefinition, TlvInput, TlvScalar, TlvValue } 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';
@@ -262,6 +262,13 @@ export function objToPdu<C extends CommandName>(obj: PduObjectInput<C>): Result<
);
}
/** A repeatable tag gathers every copy the peer sent; any other keeps the last. */
function tlvValue(definition: TlvDefinition | undefined, earlier: Tlv | undefined, value: TlvScalar): TlvValue {
if (definition?.multiple !== true) return value;
return [...Array.isArray(earlier?.tagValue) ? earlier.tagValue : [], value];
}
function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: Record<string, Tlv> }> {
const tlvs: Record<string, Tlv> = {};
let offset = start;
@@ -279,11 +286,9 @@ function parseTlvs(pdu: Buffer, start: number): Result<{ offset: number; tlvs: R
if (read.err) return { err: read.err };
tlvs[definition?.tag ?? tagId.toString()] = {
tagId,
tagName: definition?.tag,
tagValue: read.value,
};
const name = definition?.tag ?? tagId.toString();
tlvs[name] = { tagId, tagName: definition?.tag, tagValue: tlvValue(definition, tlvs[name], read.value) };
offset += 4 + tagLength;
}
+7 -4
View File
@@ -2,7 +2,7 @@ import type { Concat } from './concat.ts';
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 type { Tlv, TlvScalar } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts';
import { messageOctets } from './message-body.ts';
@@ -52,6 +52,10 @@ type Group = {
total: number;
};
function copied(value: TlvScalar): TlvScalar {
return Buffer.isBuffer(value) ? Buffer.from(value) : value;
}
/** Wire reads hand back views, so retaining one segment would pin the whole PDU it arrived in. */
function detach(pduObj: PduObject): PduObject {
const params: Record<string, ParamValue> = {};
@@ -62,9 +66,7 @@ function detach(pduObj: PduObject): PduObject {
}
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
tlvs[name] = Buffer.isBuffer(tlv.tagValue)
? { ...tlv, tagValue: Buffer.from(tlv.tagValue) }
: tlv;
tlvs[name] = { ...tlv, tagValue: Array.isArray(tlv.tagValue) ? tlv.tagValue.map(copied) : copied(tlv.tagValue) };
}
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
@@ -78,6 +80,7 @@ function detach(pduObj: PduObject): PduObject {
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
function sizeOf(value: unknown): number {
if (Buffer.isBuffer(value)) return value.length;
if (Array.isArray(value)) return value.reduce<number>((sum, copy) => sum + sizeOf(copy), 0);
return typeof value === 'string' ? value.length : 0;
}