localstorage.mjs

/** @module localstorage */

let verifiedLocalStorageSupport = false;

/**
 * Check if localStorage is available and functional. Caches the result after the first successful check for performance.
 *
 * @returns {boolean} True if localStorage is available, false otherwise
 * @example
 * if (isLocalStorageAvailable()) {
 *   setLocal('key', 'value')
 * }
 */
export function isLocalStorageAvailable() {
  if (typeof globalThis === 'undefined' || typeof globalThis.localStorage === 'undefined') return false
  if (verifiedLocalStorageSupport) return true

  try {
    const testKey = 'localStorage:test'
    globalThis.localStorage.setItem(testKey, 'value')
    globalThis.localStorage.removeItem(testKey)
    verifiedLocalStorageSupport = true
    return true
  } catch (e) {
    return false
  }
}

/**
 * Used only for testing purposes to reset the cached localStorage support check result
 * @ignore
 */
export function _resetLocalStorageSupportCheck() {
  verifiedLocalStorageSupport = false
}

/**
 * Check if a timestamp has exceeded its TTL
 *
 * @param {number} timestamp The timestamp in milliseconds to check
 * @param {number} ttl The time-to-live in milliseconds
 * @returns {boolean} True if the timestamp has expired, false otherwise
 * @example
 * isExpired(Date.now() - 1000, 500) // => true
 * isExpired(Date.now() - 500, 1000) // => false
 */
export function isExpired(timestamp, ttl) {
  return Date.now() - timestamp > ttl
}

/**
 * Retrieve a value from localStorage by key. Automatically unwraps the storage envelope and checks TTL expiration. Expired items are removed from localStorage.
 *
 * @param {string} key The localStorage key
 * @returns {*} The stored value, or null if the key doesn't exist, has expired, or localStorage is unavailable
 * @example
 * setLocal('user', { name: 'Nikola' })
 * getLocal('user') // => { name: 'Nikola' }
 * getLocal('nonexistent') // => null
 */
export function getLocal(key) {
  if (!isLocalStorageAvailable()) return null
  const item = localStorage.getItem(key)
  if (!item) return null

  try {
    const parsed = JSON.parse(item)
    const isEnvelope = parsed !== null && typeof parsed === 'object' && 'value' in parsed && '__storedAt' in parsed
    if (!isEnvelope) return parsed
    
    const envelope = parsed
    if (typeof envelope.__ttl === 'number' && isExpired(envelope.__storedAt, envelope.__ttl)) {
      localStorage.removeItem(key)
      return null
    }

    return envelope.value
  } catch (e) {
    localStorage.removeItem(key)
    return null
  }
}

/**
 * Store a JSON-serializable value in localStorage wrapped in an envelope with a timestamp. Optionally set a TTL for automatic expiration on read. When update is true, preserves the original __storedAt timestamp and TTL of an existing item.
 *
 * @param {string} key The localStorage key
 * @param {*} value The value to store (must be JSON-serializable)
 * @param {number} [ttl] Optional time-to-live in milliseconds
 * @param {boolean} [update=false] If true, preserve the original __storedAt and TTL
 * @returns {*} The stored value, or null if localStorage is unavailable, the value can't be serialized, or the item has expired (update mode)
 * @example
 * setLocal('key', 'value') // => 'value'
 * setLocal('key', { foo: 'bar' }, 60000) // expires in 1 minute
 * setLocal('key', 'new', null, true) // update value, keep original timestamp and TTL
 */
export function setLocal(key, value, ttl, update = false) {
  if (!isLocalStorageAvailable()) return null

  try {
    if (update) {
      const item = localStorage.getItem(key)
      if (item) {
        const parsed = JSON.parse(item)
        const isEnvelope = parsed !== null && typeof parsed === 'object' && 'value' in parsed && '__storedAt' in parsed
        if (isEnvelope) {
          if (typeof parsed.__ttl === 'number' && isExpired(parsed.__storedAt, parsed.__ttl)) {
            localStorage.removeItem(key)
            return null
          }
          parsed.value = value
          localStorage.setItem(key, JSON.stringify(parsed))
          return value
        }
      }
    }

    const envelope = { value, __storedAt: Date.now() }
    if (typeof ttl === 'number') envelope.__ttl = ttl
    localStorage.setItem(key, JSON.stringify(envelope))
    return value
  } catch (e) {
    return null
  }
}

/**
 * Update the value of an existing localStorage item, preserving the original __storedAt timestamp and TTL. If the key doesn't exist or has no envelope, behaves like setLocal.
 *
 * @param {string} key The localStorage key
 * @param {*} value The new value to store (must be JSON-serializable)
 * @returns {*} The stored value, or null if localStorage is unavailable, the item has expired, or the value can't be serialized
 * @example
 * setLocal('key', 'old', 60000)
 * updateLocal('key', 'new') // => 'new' (keeps original timestamp and TTL)
 */
export function updateLocal(key, value) {
  return setLocal(key, value, null, true)
}

/**
 * Remove an item from localStorage by key
 *
 * @param {string} key The localStorage key to remove
 * @example
 * setLocal('key', 'value')
 * removeLocal('key')
 * getLocal('key') // => null
 */
export function removeLocal(key) {
  if (!isLocalStorageAvailable()) {
    return;
  }
  localStorage.removeItem(key)
}