diff --git a/README.md b/README.md index 2ee0ae3..a04f05d 100644 --- a/README.md +++ b/README.md @@ -1005,7 +1005,20 @@ catch (error) { ## Changelog -### v3.2.0 (Latest) - Major Performance & API Improvements +### v3.2.1 (Latest) - Code Cleanup & Optimizations +- **Eliminated URL Parsing Duplication**: `UrlAnalyzer` class now delegates to pure functions in `url-parser.ts`, removing ~200 lines of duplicated parsing/formatting logic +- **Centralized Constants**: `INSPECT_BASE` constant defined once and imported everywhere (was duplicated in 3 files) +- **Consolidated URL Dispatch**: Extracted shared `decodeMaskedFromAnalyzed()` helper, removing repeated analyze-check-decode patterns across `index.ts` +- **Deduplicated Promise Timeout**: Extracted `waitForReady()` helper in Steam client, eliminating two identical promise-with-timeout blocks +- **CRC32 Performance**: Pre-computed CRC32 lookup table at module load instead of regenerating the 256-entry table on every `createInspectUrl()` call +- **Debug-Guarded Logging**: All `console.log`/`error`/`warn` calls in Steam client and manager now respect the `enableLogging` config flag via `debugLog()` +- **Fixed `processRarity()` Bug**: Unknown string rarity values now throw `EncodingError` instead of silently returning `STOCK` (0) +- **Fixed `cleanExpiredItems()` Performance**: Replaced O(n²) filter+indexOf+splice pattern with single O(n) reverse-iteration pass +- **Fixed Redundant Hex Validation**: Removed unreachable `>2000` length check that shadowed the correct `>4096` check +- **Removed Dead Code**: No-op ternary and deprecated `substr()` replaced with `slice()` +- **~330 lines removed** with zero public API changes - all existing tests pass + +### v3.2.0 - Major Performance & API Improvements - **True Static Methods**: All static convenience functions now use pure functions with zero instance creation - **Optimized `inspectItem()`**: Uses static methods for masked URLs, requires explicit Steam client for unmasked URLs - **Enhanced Error Messages**: Actionable suggestions, troubleshooting steps, and alternative solutions in all errors diff --git a/package-lock.json b/package-lock.json index 6651d6f..96015e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cs2-inspect-lib", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cs2-inspect-lib", - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "dependencies": { "commander": "^11.0.0", diff --git a/package.json b/package.json index c4657c2..9f33967 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cs2-inspect-lib", - "version": "3.2.0", + "version": "3.2.1", "description": "Enhanced CS2 Inspect URL library with full protobuf support, validation, and error handling", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/index.ts b/src/index.ts index e80205b..d6f00ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,43 @@ import { Validator } from './validation'; import { SteamClientManager } from './steam-client-manager'; import { InvalidUrlError, SteamNotReadyError } from './errors'; +/** + * Helper: Decodes masked protobuf data from an analyzed URL. + * Throws appropriate errors for unmasked or invalid URLs. + */ +function decodeMaskedFromAnalyzed(analyzed: AnalyzedInspectURL, config?: CS2InspectConfig): EconItem { + if (analyzed.url_type === 'masked' && analyzed.hex_data) { + return ProtobufReader.decodeMaskedData(analyzed.hex_data, config); + } + + if (analyzed.url_type === 'unmasked') { + throw new InvalidUrlError( + 'This is an unmasked URL (market/inventory link). Use inspectItem() for Steam client inspection.', + { + urlType: analyzed.url_type, + suggestion: 'For unmasked URLs, use inspectItem() with a Steam client, or create a CS2Inspect instance.', + alternatives: [ + 'Use inspectItem(url, { steamClient: manager }) - pass existing SteamClientManager', + 'Use cs2.inspectItem(url) - requires Steam client initialization', + 'Use decodeMaskedUrl() only for masked URLs (containing hex data)' + ] + } + ); + } + + throw new InvalidUrlError( + 'Invalid URL format or missing data', + { + url: analyzed.original_url, + suggestion: 'Ensure the URL is a valid CS2 inspect URL.', + expectedFormats: [ + 'Masked: steam://rungame/730/.../+csgo_econ_action_preview%20[HEX_DATA]', + 'Unmasked: steam://rungame/730/.../+csgo_econ_action_preview%20[M|S][ID]A[ASSET]D[CLASS]' + ] + } + ); +} + /** * Main CS2 Inspect URL API class */ @@ -55,21 +92,9 @@ export class CS2Inspect { /** * Creates an inspect URL from an EconItem - * + * * @param item - The item data to encode * @returns The generated inspect URL - * - * @example - * ```typescript - * const cs2 = new CS2Inspect(); - * const item: EconItem = { - * defindex: WeaponType.AK_47, - * paintindex: 44, // Fire Serpent - * paintseed: 661, - * paintwear: 0.15 - * }; - * const url = cs2.createInspectUrl(item); - * ``` */ createInspectUrl(item: EconItem): string { return ProtobufWriter.createInspectUrl(item, this.config); @@ -78,58 +103,16 @@ export class CS2Inspect { /** * Decodes a MASKED inspect URL into an EconItem (synchronous, offline) * - * ⚠️ ONLY works with MASKED URLs (URLs containing encoded protobuf data) - * ❌ Does NOT work with UNMASKED URLs (market/inventory links) - use inspectItem() instead + * Only works with MASKED URLs (URLs containing encoded protobuf data). + * Does NOT work with UNMASKED URLs (market/inventory links) - use inspectItem() instead. * * @param url - The MASKED inspect URL to decode * @returns The decoded item data * @throws Error if URL is unmasked or invalid - * - * @example - * ```typescript - * const cs2 = new CS2Inspect(); - * // This works - masked URL with protobuf data - * const maskedUrl = "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20001807A8..."; - * const item = cs2.decodeMaskedUrl(maskedUrl); - * - * // This will throw an error - unmasked URL - * const unmaskedUrl = "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20S76561198123456789A987654321D456789123"; - * // cs2.decodeMaskedUrl(unmaskedUrl); // ❌ Throws error - * ``` */ decodeMaskedUrl(url: string): EconItem { const analyzed = this.urlAnalyzer.analyzeInspectUrl(url); - - if (analyzed.url_type === 'masked' && analyzed.hex_data) { - return ProtobufReader.decodeMaskedData(analyzed.hex_data, this.config); - } - - if (analyzed.url_type === 'unmasked') { - throw new InvalidUrlError( - 'This is an unmasked URL (market/inventory link). Use inspectItem() instead for Steam client inspection.', - { - urlType: analyzed.url_type, - suggestion: 'For unmasked URLs, use cs2.inspectItem(url) after initializing Steam client, or use the inspectItem() convenience function with a SteamClientManager instance.', - alternatives: [ - 'Use cs2.inspectItem(url) - requires Steam client initialization', - 'Use inspectItem(url, { steamClient: manager }) - pass existing Steam client', - 'Use decodeMaskedUrl() only for masked URLs (containing hex data)' - ] - } - ); - } - - throw new InvalidUrlError( - 'Invalid URL format or missing data', - { - url: analyzed.original_url, - suggestion: 'Ensure the URL is a valid CS2 inspect URL. Check that it contains either hex-encoded protobuf data (masked) or Steam IDs (unmasked).', - expectedFormats: [ - 'Masked: steam://rungame/730/.../+csgo_econ_action_preview%20[HEX_DATA]', - 'Unmasked: steam://rungame/730/.../+csgo_econ_action_preview%20[M|S][ID]A[ASSET]D[CLASS]' - ] - } - ); + return decodeMaskedFromAnalyzed(analyzed, this.config); } /** @@ -145,7 +128,7 @@ export class CS2Inspect { /** * Analyzes an inspect URL structure - * + * * @param url - The URL to analyze * @returns Analyzed URL information */ @@ -155,7 +138,7 @@ export class CS2Inspect { /** * Validates an EconItem - * + * * @param item - The item to validate * @returns Validation result */ @@ -165,7 +148,7 @@ export class CS2Inspect { /** * Validates an inspect URL - * + * * @param url - The URL to validate * @returns Validation result */ @@ -191,7 +174,7 @@ export class CS2Inspect { /** * Normalizes an inspect URL to standard format - * + * * @param url - The URL to normalize * @returns Normalized URL */ @@ -212,33 +195,12 @@ export class CS2Inspect { /** * Inspects ANY inspect URL (both masked and unmasked) - Universal method * - * ✅ Works with MASKED URLs (decoded offline using protobuf data) - * ✅ Works with UNMASKED URLs (fetched via Steam client) - * 🔄 Automatically detects URL type and uses appropriate method + * Works with MASKED URLs (decoded offline using protobuf data) and + * UNMASKED URLs (fetched via Steam client). Automatically detects URL type. * * @param url - Any valid inspect URL (masked or unmasked) * @returns Promise resolving to the decoded item data * @throws Error if Steam client is required but not available - * - * @example - * ```typescript - * const cs2 = new CS2Inspect({ - * steamClient: { - * enabled: true, - * username: 'your_username', - * password: 'your_password' - * } - * }); - * await cs2.initializeSteamClient(); - * - * // Works with masked URLs (offline) - * const maskedUrl = "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20001807A8..."; - * const maskedItem = await cs2.inspectItem(maskedUrl); - * - * // Works with unmasked URLs (via Steam client) - * const unmaskedUrl = "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20S76561198123456789A987654321D456789123"; - * const unmaskedItem = await cs2.inspectItem(unmaskedUrl); - * ``` */ async inspectItem(url: string): Promise { const analyzed = this.urlAnalyzer.analyzeInspectUrl(url); @@ -370,7 +332,7 @@ export class CS2Inspect { /** * Gets current configuration - * + * * @returns Current configuration */ getConfig(): Required { @@ -380,7 +342,6 @@ export class CS2Inspect { /** * Static convenience functions for quick usage without instantiating the class - * These are optimized to avoid creating unnecessary instances */ /** @@ -392,69 +353,21 @@ export function createInspectUrl(item: EconItem, config?: CS2InspectConfig): str /** * Decodes a MASKED inspect URL into an EconItem (convenience function) - * ⚠️ ONLY works with MASKED URLs - use inspectItem() for universal support - * - * ⚡ OPTIMIZED: Uses static methods directly - no instance creation - * + * Only works with MASKED URLs - use inspectItem() for universal support + * * @param url - The MASKED inspect URL to decode * @param config - Optional configuration * @returns The decoded item data * @throws Error if URL is unmasked or invalid - * - * @example - * ```typescript - * // Fast offline decoding for masked URLs - * const item = decodeMaskedUrl(maskedUrl); - * - * // For better performance in loops, analyze once: - * const analyzed = analyzeUrl(url); - * if (analyzed.url_type === 'masked' && analyzed.hex_data) { - * const item = decodeMaskedData(analyzed.hex_data); - * } - * ``` */ export function decodeMaskedUrl(url: string, config?: CS2InspectConfig): EconItem { const analyzed = analyzeInspectUrl(url, config); - - if (analyzed.url_type === 'masked' && analyzed.hex_data) { - return ProtobufReader.decodeMaskedData(analyzed.hex_data, config); - } - - if (analyzed.url_type === 'unmasked') { - throw new InvalidUrlError( - 'This is an unmasked URL (market/inventory link). decodeMaskedUrl() only works with masked URLs.', - { - urlType: analyzed.url_type, - suggestion: 'For unmasked URLs, use inspectItem() with a Steam client, or create a CS2Inspect instance.', - alternatives: [ - 'Use inspectItem(url, { steamClient: manager }) - pass existing SteamClientManager', - 'Use const cs2 = new CS2Inspect({ steamClient: {...} }); await cs2.initializeSteamClient(); await cs2.inspectItem(url)', - 'Use decodeMaskedUrl() only for masked URLs (containing hex-encoded protobuf data)' - ], - quickFix: 'If you have a masked URL, ensure it contains hex data after the preview command.' - } - ); - } - - throw new InvalidUrlError( - 'Invalid URL format or missing data', - { - url: analyzed.original_url, - urlType: analyzed.url_type, - suggestion: 'Use analyzeUrl(url) to get detailed information about why the URL is invalid.', - expectedFormats: [ - 'Masked: steam://rungame/730/.../+csgo_econ_action_preview%20[HEX_DATA]', - 'Unmasked: steam://rungame/730/.../+csgo_econ_action_preview%20[M|S][ID]A[ASSET]D[CLASS]' - ] - } - ); + return decodeMaskedFromAnalyzed(analyzed, config); } /** * Decodes a MASKED inspect URL into an EconItem (convenience function) * @deprecated Use decodeMaskedUrl() instead for clearer naming - * - * ⚡ OPTIMIZED: Uses static methods directly - no instance creation */ export function decodeInspectUrl(url: string, config?: CS2InspectConfig): EconItem { return decodeMaskedUrl(url, config); @@ -462,24 +375,6 @@ export function decodeInspectUrl(url: string, config?: CS2InspectConfig): EconIt /** * Inspects ANY inspect URL (masked or unmasked) - Universal convenience function - * - * ⚡ OPTIMIZED: Uses static methods for masked URLs - no instance creation - * - * @example - * ```typescript - * // Masked URL (offline, no Steam client needed) - OPTIMIZED - * const item = await inspectItem(maskedUrl); - * - * // Unmasked URL (requires Steam client) - explicit - * const cs2 = new CS2Inspect({ steamClient: { enabled: true, ... } }); - * await cs2.initializeSteamClient(); - * const item = await inspectItem(unmaskedUrl, { - * steamClient: cs2.getSteamClientManager() // Pass existing instance - * }); - * - * // Or use instance method (recommended for unmasked URLs) - * const item = await cs2.inspectItem(unmaskedUrl); - * ``` */ // Overload 1: No parameters (masked URL) export function inspectItem(url: string): Promise; @@ -504,46 +399,32 @@ export async function inspectItem( steamClient?: SteamClientManager; } ): Promise { - // Check if it's the new options format (has steamClient property) - const isOptionsFormat = optionsOrConfig && - typeof optionsOrConfig === 'object' && + // Normalize arguments: extract config and steamClient from either format + const isOptionsFormat = optionsOrConfig && + typeof optionsOrConfig === 'object' && 'steamClient' in optionsOrConfig; - - if (isOptionsFormat) { - // New format: options object with explicit Steam client - const options = optionsOrConfig as { config?: CS2InspectConfig; steamClient?: SteamClientManager }; - const analyzed = analyzeInspectUrl(url, options.config); - - // Handle masked URLs (offline, no Steam client needed) - if (analyzed.url_type === 'masked' && analyzed.hex_data) { - return ProtobufReader.decodeMaskedData(analyzed.hex_data, options.config); - } - - // Handle unmasked URLs (requires Steam client) - if (analyzed.url_type === 'unmasked') { - if (!options.steamClient) { - throw new SteamNotReadyError( - 'Unmasked URL requires Steam client but none was provided.', - { - urlType: analyzed.url_type, - suggestion: 'Pass a SteamClientManager instance in the options, or use the CS2Inspect instance method instead.', - solutions: [ - { - method: 'Convenience function with Steam client', - code: 'const cs2 = new CS2Inspect({ steamClient: {...} });\nawait cs2.initializeSteamClient();\nconst item = await inspectItem(url, { steamClient: cs2.getSteamClientManager() });' - }, - { - method: 'Instance method (recommended)', - code: 'const cs2 = new CS2Inspect({ steamClient: {...} });\nawait cs2.initializeSteamClient();\nconst item = await cs2.inspectItem(url);' - } - ], - alternative: 'For masked URLs, use decodeMaskedUrl(url) which works offline without Steam client.' - } - ); - } - - if (!options.steamClient.isAvailable()) { - const status = options.steamClient.getStatus(); + + const config = isOptionsFormat + ? (optionsOrConfig as { config?: CS2InspectConfig }).config + : optionsOrConfig as CS2InspectConfig | undefined; + + const steamClient = isOptionsFormat + ? (optionsOrConfig as { steamClient?: SteamClientManager }).steamClient + : undefined; + + const analyzed = analyzeInspectUrl(url, config); + + // Handle masked URLs (offline, no Steam client needed) + if (analyzed.url_type === 'masked' && analyzed.hex_data) { + return ProtobufReader.decodeMaskedData(analyzed.hex_data, config); + } + + // Handle unmasked URLs (requires Steam client) + if (analyzed.url_type === 'unmasked') { + // If steamClient was explicitly provided, use it + if (steamClient) { + if (!steamClient.isAvailable()) { + const status = steamClient.getStatus(); throw new SteamNotReadyError( 'Steam client is not ready for inspection.', { @@ -556,74 +437,77 @@ export async function inspectItem( '3. Wait for status to be "ready" (check with getStatus() or isAvailable())', '4. Then call inspectItem()' ], - currentStatus: `Current status: ${status}`, - troubleshooting: status === 'disconnected' - ? 'Steam client is disconnected. Check credentials and network connection.' - : status === 'connecting' - ? 'Steam client is still connecting. Wait a few seconds and try again.' - : 'Check Steam client logs for connection issues.' + currentStatus: `Current status: ${status}` } ); } - - return await options.steamClient.inspectUnmaskedUrl(analyzed); + return await steamClient.inspectUnmaskedUrl(analyzed); } - - throw new InvalidUrlError( - 'Invalid URL format or missing data', - { - url: analyzed.original_url, - urlType: analyzed.url_type, - suggestion: 'Use analyzeUrl(url) to get detailed information about the URL structure and identify the issue.', - expectedFormats: [ - 'Masked: steam://rungame/730/.../+csgo_econ_action_preview%20[HEX_DATA]', - 'Unmasked: steam://rungame/730/.../+csgo_econ_action_preview%20[M|S][ID]A[ASSET]D[CLASS]' - ] + + // No explicit steamClient: try auto-initialize (backward compat for config-only format) + if (!isOptionsFormat) { + const cs2 = new CS2Inspect(config); + if (!cs2.isSteamClientReady()) { + try { + await cs2.initializeSteamClient(); + } catch (error) { + throw new SteamNotReadyError( + 'Failed to initialize Steam client for unmasked URL inspection.', + { + urlType: analyzed.url_type, + originalError: error, + suggestion: 'Ensure Steam client is properly configured with valid credentials.', + steps: [ + '1. Verify credentials in config: { steamClient: { enabled: true, username: "...", password: "..." } }', + '2. Check network connection and Steam service status', + '3. For explicit error handling, use: inspectItem(url, { steamClient: manager })' + ], + alternative: 'For masked URLs, use decodeMaskedUrl(url) which works offline without Steam client.' + } + ); + } } - ); - } else { - // Old format: config only (backward compatibility) or no config - const config = optionsOrConfig as CS2InspectConfig | undefined; - const analyzed = analyzeInspectUrl(url, config); - - if (analyzed.url_type === 'masked' && analyzed.hex_data) { - // Masked URL - use optimized static method - return ProtobufReader.decodeMaskedData(analyzed.hex_data, config); + return await cs2.inspectItem(url); } - - // Unmasked URL with old API - create instance and auto-initialize (for backward compat) - // Note: This is less optimal but maintains backward compatibility - const cs2 = new CS2Inspect(config); - if (!cs2.isSteamClientReady()) { - try { - await cs2.initializeSteamClient(); - } catch (error) { - throw new SteamNotReadyError( - 'Failed to initialize Steam client for unmasked URL inspection.', + + // Options format but no steamClient provided + throw new SteamNotReadyError( + 'Unmasked URL requires Steam client but none was provided.', + { + urlType: analyzed.url_type, + suggestion: 'Pass a SteamClientManager instance in the options, or use the CS2Inspect instance method instead.', + solutions: [ { - urlType: analyzed.url_type, - originalError: error, - suggestion: 'Ensure Steam client is properly configured with valid credentials. For better error handling, use the new API format.', - steps: [ - '1. Verify credentials in config: { steamClient: { enabled: true, username: "...", password: "..." } }', - '2. Check network connection and Steam service status', - '3. Ensure Steam account has CS2 access', - '4. For explicit error handling, use: inspectItem(url, { steamClient: manager })' - ], - alternative: 'For masked URLs, use decodeMaskedUrl(url) which works offline without Steam client.' + method: 'Convenience function with Steam client', + code: 'const cs2 = new CS2Inspect({ steamClient: {...} });\nawait cs2.initializeSteamClient();\nconst item = await inspectItem(url, { steamClient: cs2.getSteamClientManager() });' + }, + { + method: 'Instance method (recommended)', + code: 'const cs2 = new CS2Inspect({ steamClient: {...} });\nawait cs2.initializeSteamClient();\nconst item = await cs2.inspectItem(url);' } - ); + ], + alternative: 'For masked URLs, use decodeMaskedUrl(url) which works offline without Steam client.' } - } - return await cs2.inspectItem(url); + ); } + + throw new InvalidUrlError( + 'Invalid URL format or missing data', + { + url: analyzed.original_url, + urlType: analyzed.url_type, + suggestion: 'Use analyzeUrl(url) to get detailed information about the URL structure.', + expectedFormats: [ + 'Masked: steam://rungame/730/.../+csgo_econ_action_preview%20[HEX_DATA]', + 'Unmasked: steam://rungame/730/.../+csgo_econ_action_preview%20[M|S][ID]A[ASSET]D[CLASS]' + ] + } + ); } /** * Inspects ANY inspect URL (masked or unmasked) - Universal convenience function * @deprecated Use inspectItem() instead for clearer naming - * - * ⚡ OPTIMIZED: Uses static methods - no instance creation for masked URLs */ export async function decodeInspectUrlAsync( url: string, @@ -708,7 +592,7 @@ export const cs2inspect = new CS2Inspect(); /** * Version information */ -export const VERSION = '3.0.6'; +export const VERSION = '3.2.1'; /** * Library information diff --git a/src/protobuf-reader.ts b/src/protobuf-reader.ts index 35fdbb2..406509f 100644 --- a/src/protobuf-reader.ts +++ b/src/protobuf-reader.ts @@ -14,7 +14,7 @@ function hexToBytes(hexStr: string): Uint8Array { const bytes = new Uint8Array(hexStr.length / 2); for (let i = 0; i < bytes.length; i++) { - const hex = hexStr.substr(i * 2, 2); + const hex = hexStr.slice(i * 2, i * 2 + 2); const byte = parseInt(hex, 16); if (isNaN(byte)) { throw new DecodingError( diff --git a/src/protobuf-writer.ts b/src/protobuf-writer.ts index 87565c5..b3bb37a 100644 --- a/src/protobuf-writer.ts +++ b/src/protobuf-writer.ts @@ -5,6 +5,19 @@ import { EconItem, Sticker, ItemRarity, CS2InspectConfig, DEFAULT_CONFIG } from './types'; import { EncodingError, ValidationError } from './errors'; import { Validator } from './validation'; +import { INSPECT_BASE } from './utils/url-parser'; + +/** + * Pre-computed CRC32 lookup table (generated once at module load) + */ +const CRC32_TABLE = new Int32Array(256); +for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = ((c & 1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); + } + CRC32_TABLE[i] = c; +} /** * Utility functions @@ -19,14 +32,15 @@ function floatToBytes(floatValue: number): number { function processRarity(rarityValue: ItemRarity | number | string): number { if (typeof rarityValue === 'number') { return rarityValue; - } else if (typeof rarityValue === 'string') { - const enumKey = rarityValue.toUpperCase(); - if (enumKey in ItemRarity) { - return ItemRarity[enumKey as keyof typeof ItemRarity]; - } - return ItemRarity.STOCK; } - return rarityValue; + const enumKey = (rarityValue as string).toUpperCase(); + if (enumKey in ItemRarity) { + return ItemRarity[enumKey as keyof typeof ItemRarity]; + } + throw new EncodingError( + `Unknown rarity value: "${rarityValue}"`, + { value: rarityValue, validValues: Object.keys(ItemRarity).filter(k => isNaN(Number(k))) } + ); } /** @@ -344,7 +358,7 @@ export class ProtobufWriter { // Field 3: defindex (required) writer.writeTag(3, 0); - writer.writeVarint(typeof item.defindex === 'number' ? item.defindex : item.defindex); + writer.writeVarint(item.defindex); // Field 4: paintindex (required) writer.writeTag(4, 0); @@ -487,22 +501,9 @@ export class ProtobufWriter { */ static crc32(data: Uint8Array): number { let crc = -1; - const table = new Int32Array(256); - - // Generate CRC table - for (let i = 0; i < 256; i++) { - let c = i; - for (let j = 0; j < 8; j++) { - c = ((c & 1) ? (-306674912 ^ (c >>> 1)) : (c >>> 1)); - } - table[i] = c; - } - - // Calculate CRC for (let i = 0; i < data.length; i++) { - crc = (crc >>> 8) ^ table[(crc ^ data[i]) & 0xFF]; + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xFF]; } - return (crc ^ (-1)) >>> 0; } @@ -510,8 +511,6 @@ export class ProtobufWriter { * Creates a complete inspect URL from an EconItem */ static createInspectUrl(item: EconItem, config: CS2InspectConfig = {}): string { - const INSPECT_BASE = "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20"; - try { const protoData = this.encodeItemData(item, config); diff --git a/src/steam-client-manager.ts b/src/steam-client-manager.ts index 4bd138b..741ec66 100644 --- a/src/steam-client-manager.ts +++ b/src/steam-client-manager.ts @@ -28,6 +28,19 @@ export class SteamClientManager { this.config = config; } + /** + * Debug logging helper - only logs when enableLogging is true + */ + private debugLog(message: string, data?: any): void { + if (this.config.enableLogging) { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] [Steam Manager] ${message}`); + if (data) { + console.log('[Steam Manager DATA]', JSON.stringify(data, null, 2)); + } + } + } + /** * Initialize Steam client if enabled and credentials are provided */ @@ -37,12 +50,12 @@ export class SteamClientManager { } if (!this.config.enabled) { - console.log('[Steam Manager] Steam client disabled in configuration'); + this.debugLog('Steam client disabled in configuration'); return; } if (!this.config.username || !this.config.password) { - console.warn('[Steam Manager] Steam credentials not provided - unmasked URL support disabled'); + this.debugLog('Steam credentials not provided - unmasked URL support disabled'); return; } @@ -50,9 +63,9 @@ export class SteamClientManager { this.client = SteamClient.getInstance(this.config); await this.client.connect(); this.initialized = true; - console.log('[Steam Manager] Steam client initialized successfully'); + this.debugLog('Steam client initialized successfully'); } catch (error) { - console.error('[Steam Manager] Failed to initialize Steam client:', error); + this.debugLog('Failed to initialize Steam client', { error: (error as Error).message }); throw error; } } @@ -128,7 +141,7 @@ export class SteamClientManager { return result; } catch (error) { - console.error('[Steam Manager] Failed to inspect item:', error); + this.debugLog('Failed to inspect item', { error: (error as Error).message }); throw error; } } @@ -193,7 +206,7 @@ export class SteamClientManager { this.client = null; } this.initialized = false; - console.log('[Steam Manager] Steam client disconnected'); + this.debugLog('Steam client disconnected'); } /** diff --git a/src/steam-client.ts b/src/steam-client.ts index 2e2fffc..0f6865a 100644 --- a/src/steam-client.ts +++ b/src/steam-client.ts @@ -122,13 +122,13 @@ export class SteamClient extends EventEmitter { // Steam client events this.steamClient.on('error', (err: Error) => { - console.error('[Steam Client] Error:', err.message); + this.debugLog('Steam client error: ' + err.message); this.status = SteamClientStatus.ERROR; this.emit('error', { type: 'steam', error: err }); }); this.steamClient.on('loggedOn', () => { - console.log('[Steam Client] Logged into Steam'); + this.debugLog('Logged into Steam'); this.status = SteamClientStatus.CONNECTED; this.steamClient.setPersona(1); // Online this.steamClient.gamesPlayed([730]); // CS2 app ID @@ -136,33 +136,60 @@ export class SteamClient extends EventEmitter { // CS2 client events this.csgoClient.on('debug', (message: string) => { - // Only log debug messages if explicitly enabled - console.log('[CS2 Client]', message); + this.debugLog(message); }); this.csgoClient.on('connectedToGC', () => { this.status = SteamClientStatus.READY; - console.log('[CS2 Client] Connected to CS2 Game Coordinator'); + this.debugLog('Connected to CS2 Game Coordinator'); this.emit('ready'); this.processQueue(); }); this.csgoClient.on('disconnectedFromGC', (reason: any) => { - console.log('[CS2 Client] Disconnected from CS2 Game Coordinator:', reason); + this.debugLog('Disconnected from CS2 Game Coordinator', { reason }); this.status = SteamClientStatus.CONNECTED; this.emit('disconnected', reason); }); this.csgoClient.on('inspectItemInfo', (item: any) => { - console.log('[CS2 Client] Received item info:', item); + this.debugLog('Received item info', { itemKeys: item ? Object.keys(item) : [] }); }); this.csgoClient.on('connectionStatus', (status: any) => { - console.log(`[CS2 Client] Server connection status:`, status); + this.debugLog('Server connection status', { status }); this.emit('serverConnectionStatus', status); }); } + /** + * Waits for the 'ready' event with timeout and error handling + */ + private waitForReady(timeoutMessage: string, timeoutMs: number = 30000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.removeListener('ready', onReady); + this.removeListener('error', onError); + reject(new SteamTimeoutError(timeoutMessage)); + }, timeoutMs); + + const onReady = () => { + clearTimeout(timeout); + this.removeListener('error', onError); + resolve(); + }; + + const onError = (err: any) => { + clearTimeout(timeout); + this.removeListener('ready', onReady); + reject(err.error || err); + }; + + this.once('ready', onReady); + this.once('error', onError); + }); + } + /** * Connect to Steam and CS2 Game Coordinator */ @@ -183,64 +210,26 @@ export class SteamClient extends EventEmitter { // Check if already logged in if (this.steamClient.steamID) { - console.log('[CS2 Client] Already logged into Steam, checking CS2 connection...'); + this.debugLog('Already logged into Steam, checking CS2 connection...'); this.status = SteamClientStatus.CONNECTED; // If CS2 client is already connected, we're ready if (this.csgoClient && this.csgoClient.haveGCSession) { this.status = SteamClientStatus.READY; - console.log('[CS2 Client] Already connected to CS2 Game Coordinator'); - return Promise.resolve(); + this.debugLog('Already connected to CS2 Game Coordinator'); + return; } // Wait for CS2 connection if not ready - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new SteamTimeoutError('CS2 Game Coordinator connection timeout')); - }, 30000); - - const onReady = () => { - clearTimeout(timeout); - this.removeListener('error', onError); - resolve(); - }; - - const onError = (err: any) => { - clearTimeout(timeout); - this.removeListener('ready', onReady); - reject(err.error || err); - }; - - this.once('ready', onReady); - this.once('error', onError); - }); + return this.waitForReady('CS2 Game Coordinator connection timeout'); } - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new SteamTimeoutError('Steam connection timeout')); - }, 30000); - - this.steamClient.logOn({ - accountName: this.config.username, - password: this.config.password - }); - - const onReady = () => { - clearTimeout(timeout); - this.removeListener('error', onError); - resolve(); - }; - - const onError = (err: any) => { - clearTimeout(timeout); - this.removeListener('ready', onReady); - reject(err.error || err); - }; - - this.once('ready', onReady); - this.once('error', onError); + this.steamClient.logOn({ + accountName: this.config.username, + password: this.config.password }); + + return this.waitForReady('Steam connection timeout'); } /** @@ -377,19 +366,14 @@ export class SteamClient extends EventEmitter { */ private cleanExpiredItems(): void { const now = Date.now(); - const expiredItems = this.queue.filter(item => - now - item.timestamp > this.config.queueTimeout - ); - - expiredItems.forEach(item => { - item.reject(new SteamTimeoutError('Request timeout', { - queueTimeout: this.config.queueTimeout - })); - const index = this.queue.indexOf(item); - if (index > -1) { - this.queue.splice(index, 1); + for (let i = this.queue.length - 1; i >= 0; i--) { + if (now - this.queue[i].timestamp > this.config.queueTimeout) { + this.queue[i].reject(new SteamTimeoutError('Request timeout', { + queueTimeout: this.config.queueTimeout + })); + this.queue.splice(i, 1); } - }); + } } /** diff --git a/src/url-analyzer.ts b/src/url-analyzer.ts index e2b864f..72fcfb6 100644 --- a/src/url-analyzer.ts +++ b/src/url-analyzer.ts @@ -3,10 +3,7 @@ */ import { AnalyzedInspectURL, CS2InspectConfig, DEFAULT_CONFIG } from './types'; -import { InvalidUrlError } from './errors'; -import { Validator } from './validation'; - -const INSPECT_BASE = "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20"; +import { parseInspectUrl, formatInspectUrl as formatInspectUrlPure } from './utils/url-parser'; /** * Enhanced URL analyzer with comprehensive validation @@ -22,209 +19,20 @@ export class UrlAnalyzer { * Analyzes and parses an inspect URL */ analyzeInspectUrl(url: string): AnalyzedInspectURL { - if (this.config.validateInput) { - const validation = Validator.validateInspectUrl(url); - if (!validation.valid) { - throw new InvalidUrlError( - `URL validation failed: ${validation.errors.join(', ')}`, - { errors: validation.errors, warnings: validation.warnings } - ); - } - } - - try { - // Clean and normalize the URL - let cleanedUrl = url.trim(); - const originalUrl = url; - - // Handle various URL formats - if (!cleanedUrl.startsWith("steam://")) { - const previewVariants = [ - "csgo_econ_action_preview ", - "csgo_econ_action_preview%20", - "+csgo_econ_action_preview ", - "+csgo_econ_action_preview%20" - ]; - - for (const variant of previewVariants) { - if (cleanedUrl.startsWith(variant)) { - cleanedUrl = INSPECT_BASE + cleanedUrl.slice(variant.length); - break; - } - } - - // Handle raw data - if (!cleanedUrl.startsWith("steam://")) { - if (cleanedUrl.startsWith("M") || cleanedUrl.startsWith("S") || /^[0-9A-F]+$/i.test(cleanedUrl)) { - cleanedUrl = INSPECT_BASE + cleanedUrl; - } - } - } - - // Check if URL is quoted - const isQuoted = cleanedUrl.includes("%20"); - - // Normalize to quoted format - if (!isQuoted) { - cleanedUrl = cleanedUrl.replace(/ /g, "%20"); - } - - // Extract the payload - const parts = cleanedUrl.split("csgo_econ_action_preview%20"); - if (parts.length < 2) { - throw new InvalidUrlError( - 'URL does not contain valid preview command', - { url: originalUrl, cleanedUrl } - ); - } - - const payload = parts[1]; - - if (!payload) { - throw new InvalidUrlError( - 'URL payload is empty', - { url: originalUrl, cleanedUrl } - ); - } - - // Pattern for unmasked URLs (Market/Steam profile links) - const unmaskedPattern = /^([SM])(\d+)A(\d+)D(\d+)$/; - const unmaskedMatch = payload.match(unmaskedPattern); - - if (unmaskedMatch) { - const [, typeChar, idValue, assetId, classId] = unmaskedMatch; - - // Validate numeric values - if (!this.isValidId(idValue) || !this.isValidId(assetId) || !this.isValidId(classId)) { - throw new InvalidUrlError( - 'Invalid numeric values in unmasked URL', - { typeChar, idValue, assetId, classId } - ); - } - - return { - original_url: originalUrl, - cleaned_url: cleanedUrl, - url_type: 'unmasked', - is_quoted: isQuoted, - market_id: typeChar === 'M' ? idValue : undefined, - owner_id: typeChar === 'S' ? idValue : undefined, - asset_id: assetId, - class_id: classId - }; - } - - // Pattern for masked URLs (hex-encoded protobuf data) - const maskedPattern = /^[0-9A-Fa-f]+$/; - const maskedMatch = payload.match(maskedPattern); - - if (maskedMatch) { - // Validate hex data - if (this.config.validateInput) { - const hexValidation = Validator.validateHexData(payload); - if (!hexValidation.valid) { - throw new InvalidUrlError( - `Invalid hex data: ${hexValidation.errors.join(', ')}`, - { errors: hexValidation.errors } - ); - } - } - - return { - original_url: originalUrl, - cleaned_url: cleanedUrl, - url_type: 'masked', - is_quoted: isQuoted, - hex_data: payload.toUpperCase() - }; - } - - throw new InvalidUrlError( - 'URL payload does not match any known format', - { payload, expectedFormats: ['unmasked (M/S + numbers)', 'masked (hex data)'] } - ); - - } catch (error) { - if (error instanceof InvalidUrlError) { - throw error; - } - throw new InvalidUrlError( - 'Failed to analyze inspect URL', - { url, originalError: error } - ); - } + return parseInspectUrl(url, this.config); } /** * Formats an analyzed URL back to string format */ formatInspectUrl( - urlInfo: AnalyzedInspectURL, + urlInfo: AnalyzedInspectURL, options: { quote?: boolean; includeSteamPrefix?: boolean; } = {} ): string { - const { quote = true, includeSteamPrefix = true } = options; - - try { - let base: string; - if (includeSteamPrefix) { - if (quote) { - // INSPECT_BASE already includes %20 - base = INSPECT_BASE; - } else { - // Replace %20 with space for unquoted format - base = INSPECT_BASE.replace('%20', ' '); - } - } else { - const separator = quote ? "%20" : " "; - base = "+csgo_econ_action_preview" + separator; - } - - if (urlInfo.url_type === 'masked') { - if (!urlInfo.hex_data) { - throw new InvalidUrlError( - 'Masked URL missing hex data', - { urlInfo } - ); - } - return base + urlInfo.hex_data; - } else { - const typeChar = urlInfo.market_id ? 'M' : 'S'; - const idValue = urlInfo.market_id || urlInfo.owner_id; - - if (!idValue || !urlInfo.asset_id || !urlInfo.class_id) { - throw new InvalidUrlError( - 'Unmasked URL missing required fields', - { urlInfo } - ); - } - - return `${base}${typeChar}${idValue}A${urlInfo.asset_id}D${urlInfo.class_id}`; - } - - } catch (error) { - if (error instanceof InvalidUrlError) { - throw error; - } - throw new InvalidUrlError( - 'Failed to format inspect URL', - { urlInfo, options, originalError: error } - ); - } - } - - /** - * Validates if a string represents a valid ID (numeric and reasonable length) - */ - private isValidId(id: string): boolean { - if (!/^\d+$/.test(id)) { - return false; - } - - const num = parseInt(id, 10); - return !isNaN(num) && num >= 0 && id.length <= 20; // Reasonable length limit + return formatInspectUrlPure(urlInfo, options); } /** @@ -287,10 +95,9 @@ export class UrlAnalyzer { } /** - * Static convenience functions - OPTIMIZED: No instance creation + * Static convenience functions - No instance creation needed * These functions use pure parsing logic for maximum performance */ -import { parseInspectUrl, formatInspectUrl as formatInspectUrlPure } from './utils/url-parser'; export function analyzeInspectUrl(url: string, config?: CS2InspectConfig): AnalyzedInspectURL { const mergedConfig = { ...DEFAULT_CONFIG, ...config }; @@ -298,11 +105,10 @@ export function analyzeInspectUrl(url: string, config?: CS2InspectConfig): Analy } export function formatInspectUrl( - urlInfo: AnalyzedInspectURL, + urlInfo: AnalyzedInspectURL, options?: { quote?: boolean; includeSteamPrefix?: boolean }, _config?: CS2InspectConfig // Kept for backward compatibility but not used ): string { - // ⚡ OPTIMIZED: Uses pure function - no instance creation return formatInspectUrlPure(urlInfo, options); } @@ -316,7 +122,6 @@ export function isValidInspectUrl(url: string, config?: CS2InspectConfig): boole } export function normalizeInspectUrl(url: string, config?: CS2InspectConfig): string { - // ⚡ OPTIMIZED: Uses pure functions - no instance creation const analyzed = analyzeInspectUrl(url, config); return formatInspectUrlPure(analyzed, { quote: true, includeSteamPrefix: true }); } diff --git a/src/utils/url-parser.ts b/src/utils/url-parser.ts index dbd8200..7e8978a 100644 --- a/src/utils/url-parser.ts +++ b/src/utils/url-parser.ts @@ -7,7 +7,7 @@ import { AnalyzedInspectURL, CS2InspectConfig } from '../types'; import { InvalidUrlError } from '../errors'; import { Validator } from '../validation'; -const INSPECT_BASE = "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20"; +export const INSPECT_BASE = "steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20"; /** * Validates if a string represents a valid ID (numeric and reasonable length) diff --git a/src/validation.ts b/src/validation.ts index 0b2129e..3e93ccd 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -257,14 +257,10 @@ export class Validator { errors.push('Hex data is too short (minimum 8 bytes)'); } - if (hexData.length > 4096) { + if (hexData.length > 4096) { // 4096 hex chars = 2048 bytes errors.push('Hex data is too long (maximum 2048 bytes)'); } - if (hexData.length > 2000) { // More strict limit for very long data - errors.push('Hex data exceeds reasonable size limit'); - } - return { valid: errors.length === 0, errors