@azure/functions-extensions-servicebus
Version:
Node.js Azure ServiceBus extension implementations for Azure Functions
443 lines (397 loc) • 17.9 kB
text/typescript
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.
/**
* AMQP Property Encoder Utility
*
* Converts TypeScript/JavaScript property values to AMQP-encoded byte arrays.
* Date objects are automatically converted to UTC ISO format for cross-platform compatibility.
*/
/**
* Supported AMQP property types
*/
enum AmqpType {
Null = 'null',
Byte = 'byte',
SByte = 'sbyte',
Char = 'char',
Int16 = 'int16',
UInt16 = 'uint16',
Int32 = 'int32',
UInt32 = 'uint32',
Int64 = 'int64',
UInt64 = 'uint64',
Single = 'single',
Double = 'double',
Decimal = 'decimal',
Boolean = 'boolean',
Guid = 'guid',
String = 'string',
Uri = 'uri',
DateTime = 'datetime',
DateTimeOffset = 'datetimeoffset',
TimeSpan = 'timespan',
Stream = 'stream',
Array = 'array',
Unknown = 'unknown',
}
/**
* Type mapping from JavaScript primitive types to AMQP types
*/
const PRIMITIVE_TYPE_MAP = new Map<string, AmqpType>([
['number', AmqpType.Double],
['boolean', AmqpType.Boolean],
['string', AmqpType.String],
['bigint', AmqpType.Int64],
]);
/**
* Type conversion utilities for specific AMQP types
*
* Contains validation functions to determine if JavaScript values fit within
* specific AMQP type ranges and formats.
*/
const TypeConverters = {
/** Checks if number fits in unsigned 8-bit range (0-255) */
isByte: (value: number): boolean => Number.isInteger(value) && value >= 0 && value <= 255,
/** Checks if number fits in signed 8-bit range (-128 to 127) */
isSByte: (value: number): boolean => Number.isInteger(value) && value >= -128 && value <= 127,
/** Checks if number fits in signed 16-bit range (-32,768 to 32,767) */
isInt16: (value: number): boolean => Number.isInteger(value) && value >= -32768 && value <= 32767,
/** Checks if number fits in unsigned 16-bit range (0 to 65,535) */
isUInt16: (value: number): boolean => Number.isInteger(value) && value >= 0 && value <= 65535,
/** Checks if number fits in signed 32-bit range (-2,147,483,648 to 2,147,483,647) */
isInt32: (value: number): boolean => Number.isInteger(value) && value >= -2147483648 && value <= 2147483647,
/** Checks if number fits in unsigned 32-bit range (0 to 4,294,967,295) */
isUInt32: (value: number): boolean => Number.isInteger(value) && value >= 0 && value <= 4294967295,
/** Checks if floating-point number fits in 32-bit single precision range */
isSingle: (value: number): boolean => !Number.isInteger(value) && Math.abs(value) <= 3.4028235e38,
/** Checks if string is a single character */
isChar: (value: string): boolean => value.length === 1,
/** Checks if string matches GUID/UUID format (8-4-4-4-12 hex digits) */
isGuid: (value: string): boolean =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value),
/** Checks if string is a valid URI by attempting URL construction */
isUri: (value: string): boolean => {
try {
new URL(value);
return true;
} catch {
return false;
}
},
/** Checks if string can be parsed as a date */
isDateTime: (value: string): boolean => !isNaN(Date.parse(value)),
/** Checks if string matches TimeSpan format ([-][d.]hh:mm:ss[.fffffff]) */
isTimeSpan: (value: string): boolean => /^-?(\d+\.)?(\d{2}:)?(\d{2}:)?\d{2}(\.\d{1,7})?$/.test(value),
};
/**
* Checks if an object represents a decimal-like value structure
* @param obj - The object to check
* @returns True if the object has properties indicating it's a decimal type
*/
function isDecimalLike(obj: unknown): boolean {
return (
obj !== null &&
typeof obj === 'object' &&
'toString' in obj &&
('precision' in obj || 'scale' in obj || 'value' in obj)
);
}
/**
* Wraps a value with the appropriate AMQP type using the rhea library
* @param type - The AMQP type string identifier
* @param value - The value to wrap
* @returns The wrapped value ready for AMQP encoding
*/
function wrapAmqpValue(type: string, value: unknown): unknown {
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment
const rhea = require('rhea');
switch (type) {
case 'null':
return null;
case 'boolean':
return value;
case 'byte':
case 'sbyte':
case 'int16':
case 'uint16':
case 'int32':
case 'uint32':
case 'single':
case 'double':
return value;
case 'int64':
case 'uint64':
// Convert BigInt to number for rhea compatibility
if (typeof value === 'bigint') {
// For BigInt values that are within safe integer range, convert to number
if (value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
return rhea.types.wrap_long(Number(value));
} else {
// For very large values, handle as number but this may lose precision
// This is a limitation of the JavaScript-AMQP bridge
console.warn(`BigInt value ${value} is outside safe integer range, precision may be lost`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
return rhea.types.wrap_long(Number(value));
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
return rhea.types.wrap_long(value);
case 'decimal':
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
return rhea.types.wrap_decimal128(value);
case 'char':
case 'string':
case 'guid':
case 'uri':
case 'datetime':
case 'datetimeoffset':
case 'timespan':
return value;
case 'stream':
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
return rhea.types.wrap_binary(value);
default:
return value;
}
}
const SUPPORTED_TYPES_MESSAGE =
'Supported types: ' +
'Primitives: null, boolean, string (including char, guid, uri, datetime, timespan), ' +
'Numbers: byte, sbyte, int16, uint16, int32, uint32, int64, uint64, single, double, decimal, ' +
'Objects: Date (datetimeoffset), URL (uri), Buffer/Uint8Array (stream), arrays of supported types';
/**
* Converts a Record<string, any> to AMQP-encoded byte array
*
* @param propertiesToModify - The properties to encode
* @returns Uint8Array containing AMQP-encoded properties
*/
export function convertPropertiesToAmqpBytes(propertiesToModify: Record<string, any>): Uint8Array {
const amqpMap = new Map<string, unknown>();
for (const [key, value] of Object.entries(propertiesToModify)) {
const amqpValue = tryCreateAmqpPropertyValue(value);
if (amqpValue !== null) {
amqpMap.set(key, amqpValue);
} else {
const error = new Error(
`The key '${key}' has a value of type '${typeof value}' which is not supported for AMQP transport. ${SUPPORTED_TYPES_MESSAGE}`
);
throw error;
}
}
const encodedBytes = encodeAmqpMap(amqpMap);
return encodedBytes;
}
/**
* Encodes properties for Service Bus message operations with proper error handling
*
* This function provides a safe wrapper around AMQP property encoding specifically
* designed for Service Bus message operations (abandon, deadletter, defer).
* It handles null/undefined properties and provides operation-specific error messages.
*
* @param propertiesToModify - Optional properties to modify on the message
* @param operationName - The name of the operation (for error messages)
* @returns Encoded properties as Uint8Array, or empty array if no properties provided
* @throws Error with operation-specific context if encoding fails
*
* @example
* ```typescript
* // For abandon operation
* const encoded = encodePropertiesForOperation(
* { messageId: 'test-123', priority: 1 },
* 'abandon'
* );
*
* // For operations without properties
* const empty = encodePropertiesForOperation(undefined, 'defer');
* // Returns: new Uint8Array()
* ```
*/
export function encodePropertiesForOperation(
propertiesToModify: Record<string, any> | undefined,
operationName: string
): Uint8Array {
// Return empty array if no properties provided
if (!propertiesToModify || Object.keys(propertiesToModify).length === 0) {
return new Uint8Array();
}
try {
return new Uint8Array(convertPropertiesToAmqpBytes(propertiesToModify));
} catch (error) {
throw new Error(
`Failed to encode properties for ${operationName} operation: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Attempts to create an AMQP property value from a JavaScript value with intelligent type detection
*
* Performs automatic type detection and conversion:
* - Numbers: Detects optimal integer types (byte, int16, int32, int64) or floating point (single, double)
* - Strings: Detects char, GUID, URI, DateTime, TimeSpan patterns
* - Objects: Handles Date, URL, Buffer/Uint8Array, decimal-like objects, arrays
* - Primitives: Boolean, BigInt, null/undefined
*
* @param propertyValue - The JavaScript value to convert to AMQP format
* @returns Typed AMQP value object with { type, value } structure, or null if conversion fails
*/
function tryCreateAmqpPropertyValue(propertyValue: unknown): unknown {
if (propertyValue === null || propertyValue === undefined) {
return { type: 'null', value: null };
}
const valueType = typeof propertyValue;
switch (valueType) {
case 'string': {
const strValue = propertyValue as string;
if (strValue === '') return { type: 'string', value: strValue };
if (TypeConverters.isChar(strValue)) return { type: 'char', value: strValue };
if (TypeConverters.isGuid(strValue)) return { type: 'guid', value: strValue };
if (TypeConverters.isUri(strValue)) return { type: 'uri', value: strValue };
if (TypeConverters.isTimeSpan(strValue)) return { type: 'timespan', value: strValue };
if (TypeConverters.isDateTime(strValue)) return { type: 'datetime', value: strValue };
return { type: 'string', value: strValue };
}
case 'number': {
const numValue = propertyValue as number;
if (!Number.isFinite(numValue)) {
return numValue;
}
if (Number.isInteger(numValue)) {
if (TypeConverters.isByte(numValue)) return { type: 'byte', value: numValue };
if (TypeConverters.isSByte(numValue)) return { type: 'sbyte', value: numValue };
if (TypeConverters.isInt16(numValue)) return { type: 'int16', value: numValue };
if (TypeConverters.isUInt16(numValue)) return { type: 'uint16', value: numValue };
if (TypeConverters.isInt32(numValue)) return { type: 'int32', value: numValue };
if (TypeConverters.isUInt32(numValue)) return { type: 'uint32', value: numValue };
return { type: 'int64', value: BigInt(numValue) };
}
if (TypeConverters.isSingle(numValue)) {
return { type: 'single', value: numValue };
}
return { type: 'double', value: numValue };
}
case 'boolean':
return { type: 'boolean', value: propertyValue as boolean };
case 'bigint': {
const bigintValue = propertyValue as bigint;
if (bigintValue >= BigInt(0) && bigintValue <= BigInt('18446744073709551615')) {
return { type: 'uint64', value: bigintValue };
}
return { type: 'int64', value: bigintValue };
}
case 'object': {
if (propertyValue instanceof Date) {
return { type: 'datetimeoffset', value: propertyValue.toISOString() };
}
if (propertyValue instanceof Buffer || propertyValue instanceof Uint8Array) {
return { type: 'stream', value: propertyValue };
}
if (propertyValue instanceof URL) {
return { type: 'uri', value: propertyValue.href };
}
if (isDecimalLike(propertyValue)) {
return { type: 'decimal', value: propertyValue };
}
if (Array.isArray(propertyValue)) {
return propertyValue.map((item: unknown) => tryCreateAmqpPropertyValue(item));
}
return null;
}
default:
return null;
}
}
/**
* Encodes an AMQP map to byte array using proper AMQP encoding
*
* Uses the rhea library to create AMQP-encoded bytes compatible with .NET AMQP decoders.
* Handles typed values by applying appropriate AMQP type wrappers before encoding.
*
* @param amqpMap - Map of string keys to AMQP-compatible values
* @returns Uint8Array containing the AMQP-encoded map
* @throws Error if encoding fails
*/
function encodeAmqpMap(amqpMap: Map<string, unknown>): Uint8Array {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment
const rhea = require('rhea');
const objectMap: Record<string, unknown> = {};
for (const [key, value] of amqpMap.entries()) {
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
const typedValue = value as { type: string; value: unknown };
objectMap[key] = wrapAmqpValue(typedValue.type, typedValue.value);
} else {
objectMap[key] = value;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const wrappedMap = rhea.types.wrap_map(objectMap);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const writer = new rhea.types.Writer();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
writer.write(wrappedMap);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
const buffer = writer.toBuffer();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const result = new Uint8Array(buffer);
return result;
} catch (error) {
throw new Error(`Failed to encode AMQP map: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Gets the AMQP type identifier for a given value
*
* Analyzes the value to determine its corresponding AMQP type. Handles both
* typed values (from tryCreateAmqpPropertyValue) and raw JavaScript values.
*
* @param value - The value to analyze
* @returns The corresponding AmqpType enum value
*/
function getAmqpTypeIdentifier(value: unknown): AmqpType {
if (value === null || value === undefined) {
return AmqpType.Null;
}
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
const typedValue = value as { type: string; value: unknown };
const amqpType = Object.values(AmqpType).find((type) => type === typedValue.type);
return amqpType || AmqpType.Unknown;
}
const jsType = typeof value;
const primitiveType = PRIMITIVE_TYPE_MAP.get(jsType);
if (primitiveType) {
return primitiveType;
}
if (jsType === 'object') {
if (value instanceof Date) {
return AmqpType.DateTimeOffset;
} else if (value instanceof URL) {
return AmqpType.Uri;
} else if (value instanceof Buffer || value instanceof Uint8Array) {
return AmqpType.Stream;
} else if (Array.isArray(value)) {
// Arrays are supported, determine their type by content
return AmqpType.Array;
}
}
return AmqpType.Unknown;
}
/**
* Validates that all properties in the record are supported AMQP types
*
* @param properties - The properties to validate
* @throws Error if any property has an unsupported type
*/
export function validateAmqpProperties(properties: Record<string, any>): void {
for (const [key, value] of Object.entries(properties)) {
const amqpType = getAmqpTypeIdentifier(value);
if (amqpType === AmqpType.Unknown) {
const error = new Error(
`Property '${key}' has unsupported type '${typeof value}' for AMQP transport. ${SUPPORTED_TYPES_MESSAGE}`
);
throw error;
}
}
}