UNPKG

@voilajsx/appkit

Version:

Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development

481 lines 19.1 kB
/** * Core utility class with 12 essential JavaScript functions * @module @voilajsx/appkit/util * @file src/util/util.ts * * @llm-rule WHEN: Building apps that need common utility functions (get, chunk, slugify, debounce, etc.) * @llm-rule AVOID: Using directly - always get instance via utilClass.get() * @llm-rule NOTE: Provides 12 essential utilities: get, isEmpty, slugify, chunk, debounce, pick, unique, clamp, formatBytes, truncate, sleep, uuid */ import crypto from 'crypto'; import { createUtilityError as createError } from './defaults.js'; /** * Utility class with 12 essential JavaScript functions */ export class UtilClass { config; memoCache; debounceCache; constructor(config) { this.config = config; this.memoCache = new Map(); this.debounceCache = new Map(); // Setup cache cleanup if enabled if (config.cache.enabled) { this.setupCacheCleanup(); } } /** * Safe property access with dot notation and array indexing * @llm-rule WHEN: Accessing nested object properties safely to prevent "Cannot read property of undefined" errors * @llm-rule AVOID: Direct property access on potentially undefined objects - always use this for nested access * @llm-rule NOTE: Supports array indexing, optional chaining syntax, and type-safe defaults */ get(obj, path, defaultValue, options = {}) { if (!obj || typeof path !== 'string') { return defaultValue; } try { // Handle simple property access if (!path.includes('.') && !path.includes('[')) { const value = obj[path]; return value !== undefined ? value : defaultValue; } // Parse complex path with dots and brackets const keys = this.parsePath(path); let current = obj; for (const key of keys) { if (current == null) { if (options.throwOnMissing) { throw createError(`Property '${path}' not found`, 'get', { obj, path }); } return defaultValue; } current = current[key]; } return current !== undefined ? current : defaultValue; } catch (error) { if (options.throwOnMissing) { throw error; } return defaultValue; } } /** * Universal empty check for all JavaScript values * @llm-rule WHEN: Validating if any value is truly empty (null, undefined, {}, [], "", whitespace-only strings) * @llm-rule AVOID: Manual empty checks like !value - this handles all edge cases properly * @llm-rule NOTE: Returns true for null, undefined, {}, [], "", " ", false for 0 and false */ isEmpty(value) { // null or undefined if (value == null) return true; // Empty string or whitespace-only string if (typeof value === 'string') { return value.trim().length === 0; } // Arrays and array-like objects if (Array.isArray(value) || typeof value.length === 'number') { return value.length === 0; } // Objects (including Date, RegExp, etc.) if (typeof value === 'object') { // Handle special objects if (value instanceof Date) return false; if (value instanceof RegExp) return false; if (value instanceof Set || value instanceof Map) { return value.size === 0; } // Plain objects return Object.keys(value).length === 0; } // Numbers, booleans, functions are not empty return false; } /** * Convert text to URL-safe slugs * @llm-rule WHEN: Creating URLs, filenames, or IDs from user text input * @llm-rule AVOID: Manual string replacement - this handles unicode, special characters, and edge cases * @llm-rule NOTE: Converts "Hello World! 123" to "hello-world-123", handles accents and special characters */ slugify(text, options = {}) { if (typeof text !== 'string') { return ''; } const opts = { replacement: options.replacement || this.config.slugify.replacement, lowercase: options.lowercase !== false, strict: options.strict || this.config.slugify.strict, locale: options.locale || this.config.slugify.locale, }; let result = text.toString(); // Convert to lowercase if requested if (opts.lowercase) { result = result.toLowerCase(); } // Replace accented characters result = result.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); // Replace special characters and spaces if (opts.strict) { result = result.replace(/[^a-zA-Z0-9]/g, opts.replacement); } else { result = result .replace(/[^a-zA-Z0-9\s-_]/g, '') // Remove special chars but keep spaces, hyphens, underscores .replace(/\s+/g, opts.replacement); // Replace spaces with replacement } // Clean up multiple replacements and trim result = result .replace(new RegExp(`\\${opts.replacement}+`, 'g'), opts.replacement) .replace(new RegExp(`^\\${opts.replacement}+|\\${opts.replacement}+$`, 'g'), ''); return result; } /** * Split array into smaller chunks of specified size * @llm-rule WHEN: Processing large arrays in batches, creating pagination, or organizing data into grids * @llm-rule AVOID: Manual array slicing - this handles edge cases and provides consistent behavior * @llm-rule NOTE: chunk([1,2,3,4,5], 2) returns [[1,2], [3,4], [5]], handles empty arrays safely */ chunk(array, size, options = {}) { if (!Array.isArray(array)) { return []; } if (size <= 0) { throw createError('Chunk size must be positive', 'chunk', { array, size }); } if (array.length === 0) { return []; } // Performance warning for large arrays if (this.config.performance.enabled && array.length > this.config.performance.chunkSizeLimit) { console.warn(`[VoilaJSX Utils] Chunking large array (${array.length} items). Consider streaming or pagination.`); } const result = []; for (let i = 0; i < array.length; i += size) { const chunk = array.slice(i, i + size); // Fill incomplete chunks if requested if (options.fillIncomplete && chunk.length < size && i + size >= array.length) { const fillValue = options.fillValue; while (chunk.length < size) { chunk.push(fillValue); } } result.push(chunk); } return result; } /** * Debounce function calls to prevent excessive execution * @llm-rule WHEN: Handling user input events (search, resize, scroll) to optimize performance * @llm-rule AVOID: Manual setTimeout management - this handles cleanup and edge cases properly * @llm-rule NOTE: Delays function execution until after specified wait period, cancels previous calls */ debounce(func, wait, options = {}) { if (typeof func !== 'function') { throw createError('Debounce target must be a function', 'debounce', func); } if (wait < 0) { throw createError('Debounce wait time must be non-negative', 'debounce', wait); } const cacheKey = func.toString() + wait + JSON.stringify(options); let timeoutId = null; let lastArgs = []; let lastThis; let result; const invokeFunc = () => { result = func.apply(lastThis, lastArgs); timeoutId = null; return result; }; const leadingEdge = () => { if (options.leading) { result = invokeFunc(); } timeoutId = setTimeout(trailingEdge, wait); return result; }; const trailingEdge = () => { timeoutId = null; if (options.trailing !== false) { result = invokeFunc(); } return result; }; const debounced = function (...args) { lastArgs = args; lastThis = this; if (timeoutId === null) { return leadingEdge(); } else { clearTimeout(timeoutId); timeoutId = setTimeout(trailingEdge, wait); } return result; }; debounced.cancel = () => { if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; } }; debounced.flush = () => { if (timeoutId !== null) { clearTimeout(timeoutId); return trailingEdge(); } return result; }; return debounced; } /** * Extract specific properties from an object * @llm-rule WHEN: Cleaning API responses, extracting specific data, or creating object subsets * @llm-rule AVOID: Manual property extraction - this handles nested keys and edge cases * @llm-rule NOTE: pick(user, ['id', 'name', 'email']) returns object with only specified properties */ pick(obj, keys) { if (!obj || typeof obj !== 'object') { return {}; } if (!Array.isArray(keys)) { throw createError('Keys must be an array', 'pick', { obj, keys }); } const result = {}; for (const key of keys) { if (key in obj) { result[key] = obj[key]; } } return result; } /** * Remove duplicate values from array * @llm-rule WHEN: Cleaning arrays with duplicate values, creating unique lists * @llm-rule AVOID: Manual duplicate removal - this is optimized and handles all value types * @llm-rule NOTE: Uses Set for performance, handles primitives and object references */ unique(array) { if (!Array.isArray(array)) { return []; } if (array.length === 0) { return []; } // Use Set for performance with primitive values if (array.length < 1000 || array.every(item => typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean' || item === null || item === undefined)) { return Array.from(new Set(array)); } // Fallback for complex objects const seen = new Map(); const result = []; for (const item of array) { const key = typeof item === 'object' && item !== null ? JSON.stringify(item) : item; if (!seen.has(key)) { seen.set(key, true); result.push(item); } } return result; } /** * Constrain number within specified bounds * @llm-rule WHEN: Validating user input, constraining values for UI controls (volume, opacity, progress) * @llm-rule AVOID: Manual min/max checking - this handles edge cases and type conversion * @llm-rule NOTE: clamp(150, 0, 100) returns 100, clamp(-10, 0, 100) returns 0 */ clamp(value, min, max) { if (typeof value !== 'number' || isNaN(value)) { throw createError('Value must be a valid number', 'clamp', { value, min, max }); } if (typeof min !== 'number' || typeof max !== 'number') { throw createError('Min and max must be valid numbers', 'clamp', { value, min, max }); } if (min > max) { throw createError('Min value cannot be greater than max value', 'clamp', { value, min, max }); } return Math.min(Math.max(value, min), max); } /** * Format byte sizes in human-readable format * @llm-rule WHEN: Displaying file sizes, memory usage, or data transfer amounts * @llm-rule AVOID: Manual byte formatting - this handles all units and edge cases properly * @llm-rule NOTE: formatBytes(1024) returns "1 KB", formatBytes(0) returns "0 Bytes" */ formatBytes(bytes, options = {}) { if (typeof bytes !== 'number' || isNaN(bytes)) { return '0 Bytes'; } if (bytes === 0) { return '0 Bytes'; } const decimals = options.decimals !== undefined ? options.decimals : 2; const binary = options.binary !== false; // Default to binary (1024) const unitSeparator = options.unitSeparator || ' '; const base = binary ? 1024 : 1000; const units = binary ? ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] : ['Bytes', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const absBytes = Math.abs(bytes); const unitIndex = Math.floor(Math.log(absBytes) / Math.log(base)); const clampedIndex = Math.min(unitIndex, units.length - 1); const value = bytes / Math.pow(base, clampedIndex); const formattedValue = clampedIndex === 0 ? value.toString() : value.toFixed(decimals); return `${formattedValue}${unitSeparator}${units[clampedIndex]}`; } /** * Truncate text with intelligent word boundary detection * @llm-rule WHEN: Displaying text previews, card descriptions, or mobile content * @llm-rule AVOID: Simple string.slice() - this preserves words and handles edge cases * @llm-rule NOTE: truncate("Hello World", 8) returns "Hello...", respects word boundaries */ truncate(text, options) { if (typeof text !== 'string') { return ''; } const { length, suffix = '...', separator, preserveWords = true } = options; if (length <= 0) { return ''; } if (text.length <= length) { return text; } let truncated = text.slice(0, length - suffix.length); // Preserve word boundaries if (preserveWords && truncated.length > 0) { const lastSpace = truncated.lastIndexOf(' '); if (lastSpace > length * 0.5) { // Only if we don't lose too much content truncated = truncated.slice(0, lastSpace); } } // Handle custom separator if (separator) { const lastSeparator = truncated.lastIndexOf(separator); if (lastSeparator > 0) { truncated = truncated.slice(0, lastSeparator); } } return truncated + suffix; } /** * Promise-based sleep function for async delays * @llm-rule WHEN: Adding delays in async functions, rate limiting, or animation timing * @llm-rule AVOID: setTimeout in async/await contexts - this provides clean Promise-based delays * @llm-rule NOTE: await sleep(1000) pauses execution for 1 second without blocking the event loop */ sleep(ms) { if (typeof ms !== 'number' || ms < 0 || isNaN(ms)) { throw createError('Sleep duration must be a non-negative number', 'sleep', ms); } return new Promise(resolve => setTimeout(resolve, ms)); } /** * Generate RFC4122 version 4 UUID * @llm-rule WHEN: Creating unique identifiers for sessions, temporary keys, or object IDs * @llm-rule AVOID: Manual ID generation - this provides cryptographically secure UUIDs * @llm-rule NOTE: Returns standard UUID format: "f47ac10b-58cc-4372-a567-0e02b2c3d479" */ uuid() { try { // Use crypto.randomUUID if available (Node.js 14.17.0+, modern browsers) if (typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } // Fallback implementation const bytes = crypto.randomBytes(16); // Set version (4) and variant bits bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = bytes.toString('hex'); return [ hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32) ].join('-'); } catch (error) { throw createError('Failed to generate UUID', 'uuid', error); } } // Private helper methods /** * Parse object path with dot notation and array indexing */ parsePath(path) { const keys = []; let current = ''; let inBrackets = false; for (let i = 0; i < path.length; i++) { const char = path[i]; if (char === '[') { if (current) { keys.push(current); current = ''; } inBrackets = true; } else if (char === ']') { if (inBrackets && current) { // Try to parse as number for array index const num = parseInt(current, 10); keys.push(isNaN(num) ? current : num); current = ''; } inBrackets = false; } else if (char === '.' && !inBrackets) { if (current) { keys.push(current); current = ''; } } else { current += char; } } if (current) { keys.push(current); } return keys; } /** * Setup automatic cache cleanup */ setupCacheCleanup() { if (!this.config.cache.enabled) return; const cleanupInterval = Math.min(this.config.cache.ttl, 60000); // Max 1 minute setInterval(() => { const now = Date.now(); for (const [key, entry] of this.memoCache.entries()) { if (now - entry.timestamp > this.config.cache.ttl) { this.memoCache.delete(key); } } // Enforce max size if (this.memoCache.size > this.config.cache.maxSize) { const entries = Array.from(this.memoCache.entries()); entries.sort((a, b) => a[1].timestamp - b[1].timestamp); const toDelete = entries.slice(0, this.memoCache.size - this.config.cache.maxSize); for (const [key] of toDelete) { this.memoCache.delete(key); } } }, cleanupInterval).unref?.(); } } //# sourceMappingURL=util.js.map