From df46ea5d987051437af11941d21f3e85c47e8fbc Mon Sep 17 00:00:00 2001 From: Jack Misner Date: Fri, 13 Feb 2026 20:16:34 +0000 Subject: [PATCH 1/3] feat: add persistent storage with localStorage and optional TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support localStorage as an alternative to sessionStorage with configurable time-to-live for automatic expiration. Data is stored in an envelope format { params, iat, eat } for consistency across both backends. 🤖 Generated with [Nori](https://nori.ai) Co-Authored-By: Nori --- README.md | 54 ++++- __tests__/config/loader.test.ts | 55 +++++ __tests__/core/storage.test.ts | 252 +++++++++++++++++++- __tests__/docs.md | 12 +- __tests__/react/useUtmTracking.test.tsx | 84 ++++++- __tests__/setup.ts | 3 + src/config/defaults.ts | 5 +- src/config/docs.md | 6 +- src/config/loader.ts | 12 + src/core/docs.md | 9 +- src/core/index.ts | 2 + src/core/storage.ts | 292 ++++++++++++++---------- src/debug/docs.md | 6 +- src/debug/index.ts | 19 +- src/docs.md | 6 +- src/index.ts | 3 + src/react/docs.md | 9 +- src/react/useUtmTracking.ts | 9 +- src/types/docs.md | 4 +- src/types/index.ts | 19 +- 20 files changed, 692 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index 03e7dd0..0c75cfe 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A comprehensive TypeScript library for capturing, storing, and appending UTM tra - **Capture** UTM parameters from URLs - **Sanitize** parameter values to prevent XSS and injection - **PII filtering** to detect and reject/redact email addresses, phone numbers, and other PII -- **Store** in sessionStorage for the browser session +- **Store** in sessionStorage or localStorage (with optional TTL) - **Append** UTM parameters to share URLs - **Configurable** key format (snake_case or camelCase) - **Platform-specific** share context parameters @@ -126,7 +126,7 @@ const params = captureUtmParameters(url, { #### `storeUtmParameters(params, options?)` -Store UTM parameters in sessionStorage. +Store UTM parameters in browser storage. ```typescript storeUtmParameters({ utm_source: 'linkedin', utm_campaign: 'sale' }); @@ -134,17 +134,26 @@ storeUtmParameters({ utm_source: 'linkedin', utm_campaign: 'sale' }); // With custom storage key storeUtmParameters(params, { storageKey: 'myapp_utm' }); +// Store in localStorage (persists across sessions) +storeUtmParameters(params, { storageType: 'local' }); + +// Store in localStorage with 1-hour TTL +storeUtmParameters(params, { storageType: 'local', ttl: 3600000 }); + // Store in camelCase format storeUtmParameters(params, { keyFormat: 'camelCase' }); ``` #### `getStoredUtmParameters(options?)` -Retrieve stored UTM parameters. +Retrieve stored UTM parameters. Returns null if data has expired (when TTL was set). ```typescript const params = getStoredUtmParameters(); +// Read from localStorage +const params = getStoredUtmParameters({ storageType: 'local' }); + // With options const params = getStoredUtmParameters({ storageKey: 'myapp_utm', @@ -167,13 +176,14 @@ const url = appendUtmParameters(url, params, { }); ``` -#### `clearStoredUtmParameters(storageKey?)` +#### `clearStoredUtmParameters(storageKey?, storageType?)` Clear stored UTM parameters. ```typescript clearStoredUtmParameters(); clearStoredUtmParameters('myapp_utm'); // Custom key +clearStoredUtmParameters('utm_parameters', 'local'); // Clear from localStorage ``` ### Key Conversion @@ -289,6 +299,34 @@ const params = captureUtmParameters(url, { Built-in PII patterns detect: email addresses, international phone numbers, UK phone numbers, and US phone numbers. +### Persistent Storage + +By default, UTM parameters are stored in `sessionStorage` (cleared when the tab closes). For longer-lived storage, use `localStorage` with an optional TTL. + +```typescript +import { storeUtmParameters, getStoredUtmParameters, createConfig } from '@jackmisner/utm-toolkit'; + +// Ephemeral storage (default) — cleared when tab closes +storeUtmParameters(params); + +// Persistent storage — survives browser restarts +storeUtmParameters(params, { storageType: 'local' }); + +// Persistent storage with 24-hour TTL — auto-expires +storeUtmParameters(params, { storageType: 'local', ttl: 86400000 }); + +// Expired data returns null and is auto-cleaned from storage +const stored = getStoredUtmParameters({ storageType: 'local' }); + +// Use with React hook +const { utmParameters } = useUtmTracking({ + config: { + storageType: 'local', + ttl: 86400000, // 24 hours + }, +}); +``` + ### Configuration ```typescript @@ -298,6 +336,7 @@ const config = createConfig({ enabled: true, keyFormat: 'snake_case', storageKey: 'utm_parameters', + storageType: 'session', captureOnMount: true, appendToShares: true, allowedParameters: ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id'], @@ -365,7 +404,9 @@ installDebugHelpers(); |--------|------|---------|-------------| | `enabled` | `boolean` | `true` | Enable/disable UTM tracking | | `keyFormat` | `'snake_case' \| 'camelCase'` | `'snake_case'` | Key format for returned params | -| `storageKey` | `string` | `'utm_parameters'` | sessionStorage key | +| `storageKey` | `string` | `'utm_parameters'` | Browser storage key | +| `storageType` | `'session' \| 'local'` | `'session'` | Storage backend (sessionStorage or localStorage) | +| `ttl` | `number` | `undefined` | Time-to-live in ms for stored params (localStorage only) | | `captureOnMount` | `boolean` | `true` | Auto-capture on React hook mount | | `appendToShares` | `boolean` | `true` | Append UTM params to share URLs | | `allowedParameters` | `string[]` | Standard UTM params | Params to capture | @@ -381,6 +422,7 @@ installDebugHelpers(); import type { UtmParameters, UtmConfig, + StorageType, SanitizeConfig, PiiFilterConfig, PiiPattern, @@ -392,7 +434,7 @@ import type { ## Browser Support - All modern browsers (Chrome, Firefox, Safari, Edge) -- Requires `sessionStorage` support +- Requires `sessionStorage` or `localStorage` support - SSR-safe (returns empty/null values on server) ## Migration from Existing Projects diff --git a/__tests__/config/loader.test.ts b/__tests__/config/loader.test.ts index eae987b..bfa8f46 100644 --- a/__tests__/config/loader.test.ts +++ b/__tests__/config/loader.test.ts @@ -437,6 +437,61 @@ describe('piiFiltering config', () => { }) }) +describe('storageType and ttl config', () => { + it('createConfig defaults storageType to session', () => { + const config = createConfig() + expect(config.storageType).toBe('session') + }) + + it('createConfig defaults ttl to undefined', () => { + const config = createConfig() + expect(config.ttl).toBeUndefined() + }) + + it('createConfig accepts storageType override', () => { + const config = createConfig({ storageType: 'local' }) + expect(config.storageType).toBe('local') + }) + + it('createConfig accepts ttl override', () => { + const config = createConfig({ storageType: 'local', ttl: 3600000 }) + expect(config.ttl).toBe(3600000) + }) + + it('mergeConfig merges storageType', () => { + const base = createConfig() + const merged = mergeConfig(base, { storageType: 'local' }) + expect(merged.storageType).toBe('local') + }) + + it('mergeConfig merges ttl', () => { + const base = createConfig() + const merged = mergeConfig(base, { ttl: 60000 }) + expect(merged.ttl).toBe(60000) + }) + + it('validateConfig validates storageType is session or local', () => { + const errors = validateConfig({ storageType: 'indexeddb' }) + expect(errors).toContain('storageType must be "session" or "local"') + }) + + it('validateConfig accepts valid storageType values', () => { + expect(validateConfig({ storageType: 'session' })).toEqual([]) + expect(validateConfig({ storageType: 'local' })).toEqual([]) + }) + + it('validateConfig validates ttl is a positive finite number', () => { + expect(validateConfig({ ttl: -1 })).toContain('ttl must be a positive finite number') + expect(validateConfig({ ttl: 'string' })).toContain('ttl must be a positive finite number') + expect(validateConfig({ ttl: NaN })).toContain('ttl must be a positive finite number') + expect(validateConfig({ ttl: Infinity })).toContain('ttl must be a positive finite number') + }) + + it('validateConfig accepts valid ttl', () => { + expect(validateConfig({ ttl: 3600000 })).toEqual([]) + }) +}) + describe('getDefaultConfig', () => { it('returns a copy of default config', () => { const config1 = getDefaultConfig() diff --git a/__tests__/core/storage.test.ts b/__tests__/core/storage.test.ts index 58e3236..2743a1c 100644 --- a/__tests__/core/storage.test.ts +++ b/__tests__/core/storage.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { storeUtmParameters, getStoredUtmParameters, clearStoredUtmParameters, hasStoredUtmParameters, isSessionStorageAvailable, + isLocalStorageAvailable, + isStorageAvailable, getRawStoredValue, DEFAULT_STORAGE_KEY, } from '../../src/core/storage' @@ -18,7 +20,11 @@ describe('storeUtmParameters', () => { storeUtmParameters({ utm_source: 'test', utm_campaign: 'sale' }) const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY) - expect(stored).toBe('{"utm_source":"test","utm_campaign":"sale"}') + expect(stored).not.toBeNull() + const parsed = JSON.parse(stored!) + expect(parsed.params).toEqual({ utm_source: 'test', utm_campaign: 'sale' }) + expect(parsed).toHaveProperty('iat') + expect(parsed).toHaveProperty('eat') }) it('uses default storage key', () => { @@ -42,14 +48,16 @@ describe('storeUtmParameters', () => { storeUtmParameters({ utmSource: 'test', utmCampaign: 'sale' }, { keyFormat: 'snake_case' }) const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY) - expect(stored).toBe('{"utm_source":"test","utm_campaign":"sale"}') + const parsed = JSON.parse(stored!) + expect(parsed.params).toEqual({ utm_source: 'test', utm_campaign: 'sale' }) }) it('stores in camelCase when specified', () => { storeUtmParameters({ utm_source: 'test' }, { keyFormat: 'camelCase' }) const stored = sessionStorage.getItem(DEFAULT_STORAGE_KEY) - expect(stored).toBe('{"utmSource":"test"}') + const parsed = JSON.parse(stored!) + expect(parsed.params).toEqual({ utmSource: 'test' }) }) it('fails silently on storage error', () => { @@ -250,3 +258,239 @@ describe('integration: store and retrieve', () => { expect(retrievedSnake).toEqual({ utm_source: 'test', utm_medium: 'email' }) }) }) + +describe('localStorage backend', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + }) + + it('stores UTM parameters in localStorage when storageType is local', () => { + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local' }) + + const stored = localStorage.getItem(DEFAULT_STORAGE_KEY) + expect(stored).not.toBeNull() + expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull() + }) + + it('retrieves UTM parameters from localStorage', () => { + storeUtmParameters({ utm_source: 'test', utm_campaign: 'sale' }, { storageType: 'local' }) + + const result = getStoredUtmParameters({ storageType: 'local' }) + expect(result).toEqual({ utm_source: 'test', utm_campaign: 'sale' }) + }) + + it('clears UTM parameters from localStorage', () => { + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local' }) + clearStoredUtmParameters(DEFAULT_STORAGE_KEY, 'local') + + expect(localStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull() + }) + + it('hasStoredUtmParameters checks localStorage', () => { + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local' }) + expect(hasStoredUtmParameters(DEFAULT_STORAGE_KEY, 'local')).toBe(true) + expect(hasStoredUtmParameters(DEFAULT_STORAGE_KEY)).toBe(false) + }) + + it('getRawStoredValue reads from localStorage', () => { + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local' }) + const raw = getRawStoredValue(DEFAULT_STORAGE_KEY, 'local') + expect(raw).not.toBeNull() + }) + + it('round-trips with key format conversion via localStorage', () => { + storeUtmParameters({ utmSource: 'test' }, { storageType: 'local', keyFormat: 'camelCase' }) + + const result = getStoredUtmParameters({ storageType: 'local', keyFormat: 'camelCase' }) + expect(result).toEqual({ utmSource: 'test' }) + }) + + it('defaults to sessionStorage when storageType not specified', () => { + storeUtmParameters({ utm_source: 'test' }) + + expect(sessionStorage.getItem(DEFAULT_STORAGE_KEY)).not.toBeNull() + expect(localStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull() + }) +}) + +describe('envelope format', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + }) + + it('stores data in envelope format with iat and eat fields', () => { + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local' }) + + const raw = localStorage.getItem(DEFAULT_STORAGE_KEY) + expect(raw).not.toBeNull() + const parsed = JSON.parse(raw!) + expect(parsed).toHaveProperty('params') + expect(parsed).toHaveProperty('iat') + expect(parsed.params).toEqual({ utm_source: 'test' }) + expect(typeof parsed.iat).toBe('number') + }) + + it('stores envelope with eat: null when no TTL', () => { + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local' }) + + const raw = localStorage.getItem(DEFAULT_STORAGE_KEY) + const parsed = JSON.parse(raw!) + expect(parsed.eat).toBeNull() + }) + + it('stores envelope with calculated eat when TTL is provided', () => { + const ttl = 3600000 // 1 hour + const before = Date.now() + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local', ttl }) + + const raw = localStorage.getItem(DEFAULT_STORAGE_KEY) + const parsed = JSON.parse(raw!) + expect(parsed.eat).toBeGreaterThanOrEqual(before + ttl) + expect(parsed.eat).toBeLessThanOrEqual(Date.now() + ttl) + }) + + it('stores sessionStorage data in envelope format too', () => { + storeUtmParameters({ utm_source: 'test' }) + + const raw = sessionStorage.getItem(DEFAULT_STORAGE_KEY) + const parsed = JSON.parse(raw!) + expect(parsed).toHaveProperty('params') + expect(parsed).toHaveProperty('iat') + expect(parsed.eat).toBeNull() + expect(parsed.params).toEqual({ utm_source: 'test' }) + }) +}) + +describe('TTL expiration', () => { + beforeEach(() => { + vi.useFakeTimers() + localStorage.clear() + sessionStorage.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns params when TTL has not expired', () => { + const ttl = 3600000 // 1 hour + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local', ttl }) + + // Advance 30 minutes (not expired) + vi.advanceTimersByTime(1800000) + + const result = getStoredUtmParameters({ storageType: 'local' }) + expect(result).toEqual({ utm_source: 'test' }) + }) + + it('returns null when TTL has expired', () => { + const ttl = 3600000 // 1 hour + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local', ttl }) + + // Advance past TTL + vi.advanceTimersByTime(3600001) + + const result = getStoredUtmParameters({ storageType: 'local' }) + expect(result).toBeNull() + }) + + it('auto-clears storage when TTL has expired', () => { + const ttl = 3600000 // 1 hour + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local', ttl }) + + // Advance past TTL + vi.advanceTimersByTime(3600001) + + // Read triggers auto-clear + getStoredUtmParameters({ storageType: 'local' }) + expect(localStorage.getItem(DEFAULT_STORAGE_KEY)).toBeNull() + }) + + it('returns params indefinitely when no TTL (eat is null)', () => { + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local' }) + + // Advance a very long time + vi.advanceTimersByTime(365 * 24 * 3600000) + + const result = getStoredUtmParameters({ storageType: 'local' }) + expect(result).toEqual({ utm_source: 'test' }) + }) + + it('hasStoredUtmParameters returns false when TTL expired', () => { + const ttl = 60000 // 1 minute + storeUtmParameters({ utm_source: 'test' }, { storageType: 'local', ttl }) + + vi.advanceTimersByTime(60001) + + expect(hasStoredUtmParameters(DEFAULT_STORAGE_KEY, 'local')).toBe(false) + }) + + it('TTL is ignored for sessionStorage (eat is always null)', () => { + storeUtmParameters({ utm_source: 'test' }, { ttl: 1000 }) + + const raw = sessionStorage.getItem(DEFAULT_STORAGE_KEY) + const parsed = JSON.parse(raw!) + expect(parsed.eat).toBeNull() + }) +}) + +describe('backward compatibility: flat format', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + }) + + it('reads flat format from sessionStorage (pre-envelope data)', () => { + // Simulate data stored by old version (flat format, no envelope) + sessionStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"old_data"}') + + const result = getStoredUtmParameters() + expect(result).toEqual({ utm_source: 'old_data' }) + }) + + it('reads flat format from localStorage (pre-envelope data)', () => { + localStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"old_data"}') + + const result = getStoredUtmParameters({ storageType: 'local' }) + expect(result).toEqual({ utm_source: 'old_data' }) + }) + + it('flat format data has no TTL expiration', () => { + vi.useFakeTimers() + localStorage.setItem(DEFAULT_STORAGE_KEY, '{"utm_source":"old_data"}') + + vi.advanceTimersByTime(365 * 24 * 3600000) + + const result = getStoredUtmParameters({ storageType: 'local' }) + expect(result).toEqual({ utm_source: 'old_data' }) + vi.useRealTimers() + }) +}) + +describe('isStorageAvailable', () => { + it('returns true for session storage when available', () => { + expect(isStorageAvailable('session')).toBe(true) + }) + + it('returns true for local storage when available', () => { + expect(isStorageAvailable('local')).toBe(true) + }) + + it('defaults to session storage when no type specified', () => { + expect(isStorageAvailable()).toBe(true) + }) +}) + +describe('isLocalStorageAvailable', () => { + it('returns true when localStorage is available', () => { + expect(isLocalStorageAvailable()).toBe(true) + }) +}) + +describe('isSessionStorageAvailable (deprecated)', () => { + it('still works but calls isStorageAvailable internally', () => { + expect(isSessionStorageAvailable()).toBe(true) + }) +}) diff --git a/__tests__/docs.md b/__tests__/docs.md index d355545..8814294 100644 --- a/__tests__/docs.md +++ b/__tests__/docs.md @@ -17,15 +17,15 @@ Path: @/__tests__ ### Core Implementation -- **`setup.ts`**: Creates a fresh sessionStorage mock and location mock in `beforeEach`, ensuring tests are isolated. The storage mock implements `getItem`, `setItem`, `removeItem`, `clear`, `length`, and `key`. Location is stubbed with `href`, `search`, `hash`, `pathname`, `protocol`, `host`, and `hostname`. -- **`core/` tests**: Cover capture (URL parsing, allowed parameters, key format conversion, SSR fallback, sanitization integration, PII filtering integration), sanitizer (HTML stripping, control character removal, custom patterns, truncation, combined rules), pii-filter (pattern detection, reject/redact modes, allowlist, callback, disabled patterns, edge cases), storage (write/read/clear, format conversion, validation of stored data, silent failure), appender (query/fragment placement, preserveExisting, remove, extract), keys (bidirectional conversion, standard and custom keys, detection, validation), and validator (protocol, domain, normalization, mutable default protocol). -- **`config/` tests**: Cover `createConfig` merging semantics (nullish coalescing, array replacement, object merge), `validateConfig` error messages, `loadConfigFromJson` fallback behavior, sanitize config handling (default inclusion, partial merge, custom pattern preservation, validation of each sanitize field), and piiFiltering config handling (default inclusion, partial merge, custom patterns replacement, mode validation). -- **`react/` tests**: Use `@testing-library/react` `renderHook` and `render` to test `useUtmTracking` (auto-capture, manual capture, clear, appendToUrl with share context and exclusions, sanitization, PII filtering) and `UtmProvider`/`useUtmContext` (context propagation, error on missing provider). +- **`setup.ts`**: Creates fresh sessionStorage and localStorage mocks and a location mock in `beforeEach`, ensuring tests are isolated. Both storage mocks implement `getItem`, `setItem`, `removeItem`, `clear`, `length`, and `key`. Location is stubbed with `href`, `search`, `hash`, `pathname`, `protocol`, `host`, and `hostname`. +- **`core/` tests**: Cover capture (URL parsing, allowed parameters, key format conversion, SSR fallback, sanitization integration, PII filtering integration), sanitizer (HTML stripping, control character removal, custom patterns, truncation, combined rules), pii-filter (pattern detection, reject/redact modes, allowlist, callback, disabled patterns, edge cases), storage (write/read/clear, format conversion, validation of stored data, silent failure, localStorage backend, envelope format, TTL expiration with fake timers, backward compatibility with flat format data, `isStorageAvailable`/`isLocalStorageAvailable` availability checks), appender (query/fragment placement, preserveExisting, remove, extract), keys (bidirectional conversion, standard and custom keys, detection, validation), and validator (protocol, domain, normalization, mutable default protocol). +- **`config/` tests**: Cover `createConfig` merging semantics (nullish coalescing, array replacement, object merge, `storageType` and `ttl` merging), `validateConfig` error messages (including `storageType` and `ttl` validation), `loadConfigFromJson` fallback behavior, sanitize config handling (default inclusion, partial merge, custom pattern preservation, validation of each sanitize field), and piiFiltering config handling (default inclusion, partial merge, custom patterns replacement, mode validation). +- **`react/` tests**: Use `@testing-library/react` `renderHook` and `render` to test `useUtmTracking` (auto-capture, manual capture, clear, appendToUrl with share context and exclusions, sanitization, PII filtering, `storageType` forwarding to storage calls) and `UtmProvider`/`useUtmContext` (context propagation, error on missing provider). ### Things to Know -- The sessionStorage mock uses `vi.fn()` wrappers, which means tests can assert on call counts and arguments (`sessionStorage.setItem` calls, etc.). +- Both the sessionStorage and localStorage mocks use `vi.fn()` wrappers, which means tests can assert on call counts and arguments (e.g., `sessionStorage.setItem` or `localStorage.setItem` calls). - `window.location` is stubbed globally rather than using JSDOM's location, so tests that need specific URLs must override `location.href` and `location.search` in their setup. -- The `beforeEach` in `setup.ts` resets both mocks, so each test starts with empty storage and a clean `https://example.com` location. +- The `beforeEach` in `setup.ts` resets both storage mocks and the location mock, so each test starts with empty storage and a clean `https://example.com` location. Created and maintained by Nori. diff --git a/__tests__/react/useUtmTracking.test.tsx b/__tests__/react/useUtmTracking.test.tsx index dfe2567..98f3eac 100644 --- a/__tests__/react/useUtmTracking.test.tsx +++ b/__tests__/react/useUtmTracking.test.tsx @@ -88,7 +88,9 @@ describe('useUtmTracking', () => { }) const stored = sessionStorage.getItem('utm_parameters') - expect(stored).toBe('{"utm_source":"captured"}') + expect(stored).not.toBeNull() + const parsed = JSON.parse(stored!) + expect(parsed.params).toEqual({ utm_source: 'captured' }) }) it('uses default params when no UTM params in URL', () => { @@ -352,6 +354,86 @@ describe('useUtmTracking', () => { }) }) + describe('storage type', () => { + it('stores captured params in localStorage when storageType is local', () => { + vi.stubGlobal('location', { + href: 'https://example.com?utm_source=local_test', + search: '?utm_source=local_test', + }) + + const { result } = renderHook(() => + useUtmTracking({ + config: { captureOnMount: false, storageType: 'local' }, + }), + ) + + act(() => { + result.current.capture() + }) + + const raw = localStorage.getItem('utm_parameters') + expect(raw).not.toBeNull() + expect(sessionStorage.getItem('utm_parameters')).toBeNull() + }) + + it('initializes from localStorage when storageType is local', () => { + // Pre-populate localStorage with envelope format + const envelope = JSON.stringify({ + params: { utm_source: 'stored_local' }, + iat: Date.now(), + eat: null, + }) + localStorage.setItem('utm_parameters', envelope) + + const { result } = renderHook(() => useUtmTracking({ config: { storageType: 'local' } })) + + expect(result.current.utmParameters).toEqual({ utm_source: 'stored_local' }) + }) + + it('stores captured params with TTL when configured', () => { + vi.stubGlobal('location', { + href: 'https://example.com?utm_source=ttl_test', + search: '?utm_source=ttl_test', + }) + + const ttl = 3600000 // 1 hour + const before = Date.now() + const { result } = renderHook(() => + useUtmTracking({ + config: { captureOnMount: false, storageType: 'local', ttl }, + }), + ) + + act(() => { + result.current.capture() + }) + + const raw = localStorage.getItem('utm_parameters') + expect(raw).not.toBeNull() + const parsed = JSON.parse(raw!) + expect(parsed.params).toEqual({ utm_source: 'ttl_test' }) + expect(parsed.eat).toBeGreaterThanOrEqual(before + ttl) + }) + + it('clears localStorage when storageType is local', () => { + const envelope = JSON.stringify({ + params: { utm_source: 'test' }, + iat: Date.now(), + eat: null, + }) + localStorage.setItem('utm_parameters', envelope) + + const { result } = renderHook(() => useUtmTracking({ config: { storageType: 'local' } })) + + act(() => { + result.current.clear() + }) + + expect(result.current.utmParameters).toBeNull() + expect(localStorage.getItem('utm_parameters')).toBeNull() + }) + }) + describe('key format', () => { it('uses snake_case by default', () => { vi.stubGlobal('location', { diff --git a/__tests__/setup.ts b/__tests__/setup.ts index bab1938..da661db 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -26,6 +26,9 @@ beforeEach(() => { const sessionStorageMock = createStorageMock() vi.stubGlobal('sessionStorage', sessionStorageMock) + const localStorageMock = createStorageMock() + vi.stubGlobal('localStorage', localStorageMock) + // Reset location mock vi.stubGlobal('location', { href: 'https://example.com', diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 6e19276..93d424c 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -76,9 +76,12 @@ export const DEFAULT_CONFIG: ResolvedUtmConfig = { /** Use snake_case format by default (matches URL query params) */ keyFormat: 'snake_case', - /** Default storage key for sessionStorage */ + /** Default storage key for browser storage */ storageKey: 'utm_parameters', + /** Use sessionStorage by default (ephemeral, cleared on tab close) */ + storageType: 'session', + /** Auto-capture UTM params when React hook mounts */ captureOnMount: true, diff --git a/src/config/docs.md b/src/config/docs.md index 302c964..62284d6 100644 --- a/src/config/docs.md +++ b/src/config/docs.md @@ -12,17 +12,17 @@ Path: @/src/config - `createConfig()` is the primary entry point, called by `useUtmTracking` in `@/src/react` to resolve user-provided partial config into a complete `ResolvedUtmConfig`. - `@/src/debug` imports `getDefaultConfig()` from here as a fallback when no config is provided to diagnostic functions. -- `DEFAULT_CONFIG` and `STANDARD_UTM_PARAMETERS` are the canonical definitions of default behavior (enabled, snake_case, sessionStorage key `utm_parameters`, auto-capture on mount, append to shares, the 6 standard UTM params). +- `DEFAULT_CONFIG` and `STANDARD_UTM_PARAMETERS` are the canonical definitions of default behavior (enabled, snake_case, `storageType: 'session'`, sessionStorage key `utm_parameters`, no TTL, auto-capture on mount, append to shares, the 6 standard UTM params). - `DEFAULT_SANITIZE_CONFIG` defines the sanitization defaults: disabled by default, but with safe-by-default values when enabled (`stripHtml: true`, `stripControlChars: true`, `maxLength: 200`). It is exported as a public constant and spread into `DEFAULT_CONFIG.sanitize`. - `DEFAULT_PII_PATTERNS` defines built-in PII detection regexes (email, phone_international, phone_uk, phone_us), all enabled by default. `DEFAULT_PII_FILTER_CONFIG` wraps these patterns with `enabled: false` and `mode: 'reject'`. Both are exported as public constants and used in `DEFAULT_CONFIG.piiFiltering`. - The config system does not perform side effects -- it is pure data transformation. ### Core Implementation -- `createConfig()` merges a partial user config with defaults using nullish coalescing (`??`) for scalar fields. Array fields (`allowedParameters`, `excludeFromShares`) are replaced wholesale when provided by the user, not merged. Object fields (`defaultParams`, `shareContextParams`) are shallow-merged. The `sanitize` field is merged via `mergeSanitizeConfig()` and `piiFiltering` via `mergePiiFilterConfig()`, both using nullish coalescing per-field so partial overrides preserve unspecified defaults. For `piiFiltering`, user-provided `patterns` replace the defaults entirely (array replacement semantics), while scalar fields like `enabled` and `mode` merge individually. +- `createConfig()` merges a partial user config with defaults using nullish coalescing (`??`) for scalar fields, including `storageType` and `ttl`. Array fields (`allowedParameters`, `excludeFromShares`) are replaced wholesale when provided by the user, not merged. Object fields (`defaultParams`, `shareContextParams`) are shallow-merged. The `sanitize` field is merged via `mergeSanitizeConfig()` and `piiFiltering` via `mergePiiFilterConfig()`, both using nullish coalescing per-field so partial overrides preserve unspecified defaults. For `piiFiltering`, user-provided `patterns` replace the defaults entirely (array replacement semantics), while scalar fields like `enabled` and `mode` merge individually. - `mergeConfig()` follows the same semantics but takes a `ResolvedUtmConfig` as the base instead of implicitly using defaults -- useful for layering configurations. - `loadConfigFromJson()` accepts `unknown` input, validates it is a non-null non-array object, then delegates to `createConfig()`. Invalid input falls back to defaults with a `console.warn`. -- `validateConfig()` performs runtime type checking on each config field and returns an array of error message strings (empty array means valid). Sanitize validation checks that `sanitize` is an object, `enabled`/`stripHtml`/`stripControlChars` are booleans, `maxLength` is a positive finite number, and `customPattern` is a RegExp. PII filtering validation checks that `piiFiltering` is an object, `enabled` is boolean, `mode` is `'reject'` or `'redact'`, and `patterns` is an array. +- `validateConfig()` performs runtime type checking on each config field and returns an array of error message strings (empty array means valid). It validates `storageType` as `'session'` or `'local'`, and `ttl` as a positive finite number. Sanitize validation checks that `sanitize` is an object, `enabled`/`stripHtml`/`stripControlChars` are booleans, `maxLength` is a positive finite number, and `customPattern` is a RegExp. PII filtering validation checks that `piiFiltering` is an object, `enabled` is boolean, `mode` is `'reject'` or `'redact'`, and `patterns` is an array. - `getDefaultConfig()` returns a shallow copy of `DEFAULT_CONFIG` with cloned arrays and objects to prevent mutation of the shared constant. For `piiFiltering`, it deep-copies each pattern object (`patterns.map(p => ({...p}))`) since patterns contain RegExp references that should not be shared. ### Things to Know diff --git a/src/config/loader.ts b/src/config/loader.ts index bfb9b8b..a20b12b 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -114,6 +114,8 @@ export function createConfig(userConfig?: Partial): ResolvedUtmConfig enabled: userConfig.enabled ?? defaults.enabled, keyFormat: userConfig.keyFormat ?? defaults.keyFormat, storageKey: userConfig.storageKey ?? defaults.storageKey, + storageType: userConfig.storageType ?? defaults.storageType, + ttl: userConfig.ttl ?? defaults.ttl, captureOnMount: userConfig.captureOnMount ?? defaults.captureOnMount, appendToShares: userConfig.appendToShares ?? defaults.appendToShares, allowedParameters: userConfig.allowedParameters @@ -147,6 +149,8 @@ export function mergeConfig( enabled: override.enabled ?? base.enabled, keyFormat: override.keyFormat ?? base.keyFormat, storageKey: override.storageKey ?? base.storageKey, + storageType: override.storageType ?? base.storageType, + ttl: override.ttl ?? base.ttl, captureOnMount: override.captureOnMount ?? base.captureOnMount, appendToShares: override.appendToShares ?? base.appendToShares, allowedParameters: override.allowedParameters @@ -225,6 +229,14 @@ export function validateConfig(config: unknown): string[] { errors.push('storageKey must be a string') } + if (c.storageType !== undefined && c.storageType !== 'session' && c.storageType !== 'local') { + errors.push('storageType must be "session" or "local"') + } + + if (c.ttl !== undefined && (typeof c.ttl !== 'number' || !Number.isFinite(c.ttl) || c.ttl <= 0)) { + errors.push('ttl must be a positive finite number') + } + if (c.captureOnMount !== undefined && typeof c.captureOnMount !== 'boolean') { errors.push('captureOnMount must be a boolean') } diff --git a/src/core/docs.md b/src/core/docs.md index 999989f..8eaf054 100644 --- a/src/core/docs.md +++ b/src/core/docs.md @@ -4,7 +4,7 @@ Path: @/src/core ### Overview -- Framework-agnostic core logic for capturing UTM parameters from URLs, persisting them in sessionStorage, appending them to outbound URLs, converting between key formats, and validating URLs. +- Framework-agnostic core logic for capturing UTM parameters from URLs, persisting them in browser storage (sessionStorage or localStorage with optional TTL), appending them to outbound URLs, converting between key formats, and validating URLs. - This is the heart of the library. Every other module (`@/src/react`, `@/src/debug`) builds on top of these utilities. - All functions are SSR-safe, returning empty/null/unchanged values when browser APIs are unavailable. @@ -29,7 +29,7 @@ URL string UtmParameters object | v -[storage.ts] -- serializes to JSON, writes/reads sessionStorage, validates on read +[storage.ts] -- wraps in envelope {params, iat, eat}, serializes to JSON, writes/reads sessionStorage or localStorage, checks TTL on read, validates on read | v [appender.ts] -- converts params to snake_case, merges into target URL query/fragment @@ -46,7 +46,7 @@ URL string with UTM params - **pii-filter.ts**: `detectPii()` tests a value against an array of `PiiPattern` objects and returns the first match (or null). `filterValue()` checks a single value: if an `allowlistPattern` is configured, the value must match it to pass (allowlist takes precedence over pattern detection); otherwise, it falls back to `detectPii()`. In `reject` mode, detected PII causes the value to be dropped (returns `undefined`); in `redact` mode, the value is replaced with `'[REDACTED]'`. `filterParams()` applies `filterValue()` to every non-undefined value, omitting keys entirely in reject mode when PII is found. The optional `onPiiDetected` callback fires synchronously with `(key, value, patternName)`. -- **storage.ts**: Uses sessionStorage with a configurable key (default: `utm_parameters`). Write operations skip empty param objects and fail silently with `console.warn`. Read operations validate parsed JSON with `isValidStoredData()`, which checks that all keys pass `isUtmKey` and all values are strings or undefined. +- **storage.ts**: Supports both sessionStorage and localStorage backends, selected via `StorageType` (`'session'` | `'local'`). All stored data uses an envelope format `{ params, iat, eat }` where `iat` is the "issued at" timestamp and `eat` is the "expires at" timestamp (or `null` for no expiry). TTL is only meaningful for localStorage; sessionStorage always stores `eat: null` since session lifetime handles expiry. On read, expired data (where `Date.now() > eat`) is auto-cleared from storage and returns `null` (lazy expiration, no background timers). Backward compatibility is maintained: if stored data is in the old flat format (pre-envelope, just a plain `UtmParameters` object), it is detected by `isEnvelopeFormat()` and read without TTL checking. Write operations skip empty param objects and fail silently with `console.warn`. Read operations validate parsed JSON with `isValidStoredData()`, which checks that all keys pass `isUtmKey` and all values are strings or undefined. The `getStorageBackend()` internal function selects the correct `Storage` object and verifies it is functional via a write/read test. Three availability-check functions are exported: `isStorageAvailable(type)` (the primary generic check), `isSessionStorageAvailable()` (deprecated alias), and `isLocalStorageAvailable()`. - **appender.ts**: `appendUtmParameters()` always converts input params to snake_case before appending to URLs (URL parameters are conventionally snake_case). Supports query string or fragment placement via `AppendOptions.toFragment`. Uses a custom `buildQueryString()` that omits `=` for empty-string values. When adding to query, it also cleans conflicting UTM params from the fragment (and vice versa). `removeUtmParameters()` strips UTM params from both query and fragment. `extractUtmParameters()` pulls UTM params from both locations, with fragment params taking precedence. @@ -57,6 +57,9 @@ URL string with UTM params - **Key invariant**: All URL-facing operations use snake_case keys. The `appender` always converts to snake_case before manipulating URLs, regardless of what format the consumer passes in. This means URLs always contain `utm_source`, never `utmSource`. - **SSR safety pattern**: Each module that accesses browser APIs (`window`, `sessionStorage`, `URL`, `document`) checks for their existence before use and returns a safe fallback (empty object, null, or unchanged URL). This is consistent across all core modules. - **Silent failure**: Storage and capture operations never throw. Errors produce `console.warn` messages and return fallback values. The appender returns the original URL unchanged on failure. +- **Storage envelope format**: All new writes use the envelope format `{ params, iat, eat }`, even for sessionStorage (where `eat` is always `null`). This consistency simplifies the read path -- `isEnvelopeFormat()` detects the format, then TTL checking applies uniformly. Old flat-format data is still readable for backward compatibility but will never be written. +- **Lazy TTL expiration**: There is no background timer or polling. Expired data is only detected and cleared when `getStoredUtmParameters()` is called. This means expired data can sit in localStorage until the next read. +- **TTL is silently ignored for sessionStorage**: If a consumer passes `ttl` with `storageType: 'session'`, the TTL value is not stored (`eat: null`). Session lifetime handles expiry instead. - **validator.ts mutable state**: `defaultProtocol` is module-level mutable state modified via `setDefaultProtocol()`. This is global -- all callers share the same default protocol. Tests that call `setDefaultProtocol()` should restore the original value. - **Fragment parameter handling in appender**: When appending to query, conflicting UTM params are removed from the fragment. When appending to fragment, conflicting UTM params are removed from the query. Only fragments that contain `=` are treated as parameter-bearing; plain anchors like `#section` are left alone. - **Sanitization and PII filtering are capture-time only**: Both run during `captureUtmParameters()` before values enter the system. They do not run at storage time, append time, or on read. Values stored in sessionStorage are already sanitized/filtered if these features were enabled at capture. diff --git a/src/core/index.ts b/src/core/index.ts index 509339c..5bd12a3 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -19,7 +19,9 @@ export { getStoredUtmParameters, clearStoredUtmParameters, hasStoredUtmParameters, + isStorageAvailable, isSessionStorageAvailable, + isLocalStorageAvailable, getRawStoredValue, DEFAULT_STORAGE_KEY, type StorageOptions, diff --git a/src/core/storage.ts b/src/core/storage.ts index e0003f1..2fde416 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -1,16 +1,16 @@ /** * UTM Parameter Storage Utility * - * Manages persistence of UTM parameters in sessionStorage. - * UTM parameters are stored for the duration of the browser session and - * cleared when the browser/tab is closed. + * Manages persistence of UTM parameters in browser storage. + * Supports sessionStorage (ephemeral) and localStorage (persistent, optionally with TTL). + * Data is stored in an envelope format: { params, iat, eat }. */ -import type { KeyFormat, UtmParameters } from '../types' +import type { KeyFormat, StorageType, UtmParameters } from '../types' import { convertParams, isSnakeCaseUtmKey, isCamelCaseUtmKey, isUtmKey } from './keys' /** - * Default storage key for UTM parameters in sessionStorage + * Default storage key for UTM parameters */ export const DEFAULT_STORAGE_KEY = 'utm_parameters' @@ -23,24 +23,88 @@ export interface StorageOptions { /** Key format to store parameters in (default: 'snake_case') */ keyFormat?: KeyFormat + + /** Storage backend: 'session' or 'local' (default: 'session') */ + storageType?: StorageType + + /** TTL in milliseconds (only applies to localStorage, ignored for sessionStorage) */ + ttl?: number } /** - * Check if sessionStorage is available + * Internal envelope format for stored data */ -function isStorageAvailable(): boolean { +interface StoredUtmEnvelope { + params: UtmParameters + /** Issued at (timestamp in ms) */ + iat: number + /** Expires at (timestamp in ms, or null for no expiry) */ + eat: number | null +} + +/** + * Get the browser storage backend for the given type + */ +function getStorageBackend(type: StorageType = 'session'): Storage | null { try { - if (typeof sessionStorage === 'undefined') { - return false + const storage = type === 'local' ? localStorage : sessionStorage + if (typeof storage === 'undefined') { + return null } // Test write/read to ensure it's actually functional const testKey = '__utm_test__' - sessionStorage.setItem(testKey, 'test') - sessionStorage.removeItem(testKey) - return true + storage.setItem(testKey, 'test') + storage.removeItem(testKey) + return storage } catch { + return null + } +} + +/** + * Check if a storage backend is available + * + * @param type - Storage type to check (default: 'session') + * @returns True if the storage backend is available and functional + */ +export function isStorageAvailable(type: StorageType = 'session'): boolean { + return getStorageBackend(type) !== null +} + +/** + * Check if sessionStorage is available in the current environment + * + * @deprecated Use isStorageAvailable('session') instead + * @returns True if sessionStorage is available and functional + */ +export function isSessionStorageAvailable(): boolean { + return isStorageAvailable('session') +} + +/** + * Check if localStorage is available in the current environment + * + * @returns True if localStorage is available and functional + */ +export function isLocalStorageAvailable(): boolean { + return isStorageAvailable('local') +} + +/** + * Detect whether stored data is in the new envelope format or the old flat format + */ +function isEnvelopeFormat(data: unknown): data is StoredUtmEnvelope { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { return false } + const obj = data as Record + return ( + 'params' in obj && + typeof obj.params === 'object' && + obj.params !== null && + 'iat' in obj && + typeof obj.iat === 'number' + ) } /** @@ -79,39 +143,39 @@ function isValidStoredData(data: unknown, keyFormat?: KeyFormat): data is UtmPar } /** - * Stores UTM parameters in sessionStorage + * Stores UTM parameters in browser storage * - * Serializes the parameters as JSON and stores them for the duration of the session. - * If storage fails (quota exceeded, permissions, etc.), fails silently to avoid - * disrupting the user experience. - * SSR-safe: returns early if sessionStorage is unavailable. + * Serializes the parameters in envelope format { params, iat, eat } and stores them. + * For sessionStorage, eat is always null (session handles expiry). + * For localStorage, eat is calculated from TTL if provided, otherwise null. + * If storage fails (quota exceeded, permissions, etc.), fails silently. + * SSR-safe: returns early if storage is unavailable. * * @param params - UTM parameters to store - * @param options - Storage options including key and format + * @param options - Storage options including key, format, type, and TTL * * @example * ```typescript - * // Store with default key - * storeUtmParameters({ - * utm_source: 'linkedin', - * utm_campaign: 'spring2025' - * }) + * // Store in sessionStorage (default) + * storeUtmParameters({ utm_source: 'linkedin' }) * - * // Store with custom key - * storeUtmParameters(params, { storageKey: 'myapp_utm' }) + * // Store in localStorage + * storeUtmParameters({ utm_source: 'linkedin' }, { storageType: 'local' }) * - * // Store in camelCase format - * storeUtmParameters( - * { utmSource: 'linkedin' }, - * { keyFormat: 'camelCase' } - * ) + * // Store in localStorage with 1-hour TTL + * storeUtmParameters({ utm_source: 'linkedin' }, { storageType: 'local', ttl: 3600000 }) * ``` */ export function storeUtmParameters(params: UtmParameters, options: StorageOptions = {}): void { - const { storageKey = DEFAULT_STORAGE_KEY, keyFormat = 'snake_case' } = options + const { + storageKey = DEFAULT_STORAGE_KEY, + keyFormat = 'snake_case', + storageType = 'session', + ttl, + } = options - // SSR safety - if (!isStorageAvailable()) { + const storage = getStorageBackend(storageType) + if (!storage) { return } @@ -123,14 +187,19 @@ export function storeUtmParameters(params: UtmParameters, options: StorageOption // Convert to target format before storing const paramsToStore = convertParams(params, keyFormat) - const serialized = JSON.stringify(paramsToStore) - sessionStorage.setItem(storageKey, serialized) + + // Build envelope — TTL only applies to localStorage + const now = Date.now() + const eat = storageType === 'local' && ttl ? now + ttl : null + const envelope: StoredUtmEnvelope = { + params: paramsToStore, + iat: now, + eat, + } + + const serialized = JSON.stringify(envelope) + storage.setItem(storageKey, serialized) } catch (error) { - // Fail silently - storage errors should not break the app - // Common causes: - // - QuotaExceededError (storage full) - // - SecurityError (storage access denied) - // - Circular reference in params (JSON.stringify fails) if (typeof console !== 'undefined' && console.warn) { console.warn('Failed to store UTM parameters:', error) } @@ -138,41 +207,26 @@ export function storeUtmParameters(params: UtmParameters, options: StorageOption } /** - * Retrieves stored UTM parameters from sessionStorage - * - * Returns null if no parameters are stored or if deserialization fails. - * Validates that the stored data is a proper object with valid UTM keys. - * SSR-safe: returns null if sessionStorage is unavailable. + * Retrieves stored UTM parameters from browser storage * - * @param options - Storage options including key and expected format - * @returns Stored UTM parameters or null if not found/invalid + * Returns null if no parameters are stored, data has expired, or deserialization fails. + * Handles both envelope format (new) and flat format (backward compat). + * Auto-clears expired data from storage. + * SSR-safe: returns null if storage is unavailable. * - * @example - * ```typescript - * // Get with default key - * const params = getStoredUtmParameters() - * if (params) { - * console.log('UTM Source:', params.utm_source) - * } - * - * // Get with custom key - * const params = getStoredUtmParameters({ storageKey: 'myapp_utm' }) - * - * // Get and convert to camelCase - * const params = getStoredUtmParameters({ keyFormat: 'camelCase' }) - * // Returns: { utmSource: '...', utmCampaign: '...' } - * ``` + * @param options - Storage options including key, format, and type + * @returns Stored UTM parameters or null if not found/invalid/expired */ export function getStoredUtmParameters(options: StorageOptions = {}): UtmParameters | null { - const { storageKey = DEFAULT_STORAGE_KEY, keyFormat } = options + const { storageKey = DEFAULT_STORAGE_KEY, keyFormat, storageType = 'session' } = options - // SSR safety - if (!isStorageAvailable()) { + const storage = getStorageBackend(storageType) + if (!storage) { return null } try { - const stored = sessionStorage.getItem(storageKey) + const stored = storage.getItem(storageKey) if (stored === null) { return null @@ -180,7 +234,34 @@ export function getStoredUtmParameters(options: StorageOptions = {}): UtmParamet const parsed: unknown = JSON.parse(stored) - // Validate the parsed data + // Handle envelope format + if (isEnvelopeFormat(parsed)) { + // Check TTL expiration + if (parsed.eat !== null && Date.now() > parsed.eat) { + // Expired — auto-clear + try { + storage.removeItem(storageKey) + } catch { + // Ignore cleanup errors + } + return null + } + + // Validate the params inside the envelope + if (!isValidStoredData(parsed.params)) { + if (typeof console !== 'undefined' && console.warn) { + console.warn('Stored UTM data is invalid, ignoring') + } + return null + } + + if (keyFormat) { + return convertParams(parsed.params, keyFormat) + } + return parsed.params + } + + // Backward compatibility: flat format (pre-envelope data) if (!isValidStoredData(parsed)) { if (typeof console !== 'undefined' && console.warn) { console.warn('Stored UTM data is invalid, ignoring') @@ -188,17 +269,11 @@ export function getStoredUtmParameters(options: StorageOptions = {}): UtmParamet return null } - // Convert to requested format if specified if (keyFormat) { return convertParams(parsed, keyFormat) } - return parsed } catch (error) { - // Fail silently and return null - // Common causes: - // - JSON.parse error (invalid JSON) - // - SecurityError (storage access denied) if (typeof console !== 'undefined' && console.warn) { console.warn('Failed to retrieve stored UTM parameters:', error) } @@ -207,32 +282,23 @@ export function getStoredUtmParameters(options: StorageOptions = {}): UtmParamet } /** - * Removes stored UTM parameters from sessionStorage - * - * Fails silently if removal fails to avoid disrupting the user experience. - * SSR-safe: returns early if sessionStorage is unavailable. + * Removes stored UTM parameters from browser storage * * @param storageKey - Storage key to clear (default: 'utm_parameters') - * - * @example - * ```typescript - * // Clear with default key - * clearStoredUtmParameters() - * - * // Clear with custom key - * clearStoredUtmParameters('myapp_utm') - * ``` + * @param storageType - Storage backend to clear from (default: 'session') */ -export function clearStoredUtmParameters(storageKey: string = DEFAULT_STORAGE_KEY): void { - // SSR safety - if (!isStorageAvailable()) { +export function clearStoredUtmParameters( + storageKey: string = DEFAULT_STORAGE_KEY, + storageType: StorageType = 'session', +): void { + const storage = getStorageBackend(storageType) + if (!storage) { return } try { - sessionStorage.removeItem(storageKey) + storage.removeItem(storageKey) } catch (error) { - // Fail silently - removal errors should not break the app if (typeof console !== 'undefined' && console.warn) { console.warn('Failed to clear UTM parameters:', error) } @@ -240,53 +306,39 @@ export function clearStoredUtmParameters(storageKey: string = DEFAULT_STORAGE_KE } /** - * Checks if UTM parameters are currently stored - * - * Returns true only if valid UTM parameters are stored (not just any data). - * Returns false if storage is empty, contains invalid data, or access fails. - * SSR-safe: returns false if sessionStorage is unavailable. + * Checks if valid, non-expired UTM parameters are currently stored * * @param storageKey - Storage key to check (default: 'utm_parameters') + * @param storageType - Storage backend to check (default: 'session') * @returns True if valid UTM parameters are stored, false otherwise - * - * @example - * ```typescript - * if (hasStoredUtmParameters()) { - * const params = getStoredUtmParameters() - * // Use stored parameters - * } else { - * // Capture new parameters - * } - * ``` */ -export function hasStoredUtmParameters(storageKey: string = DEFAULT_STORAGE_KEY): boolean { - const params = getStoredUtmParameters({ storageKey }) +export function hasStoredUtmParameters( + storageKey: string = DEFAULT_STORAGE_KEY, + storageType: StorageType = 'session', +): boolean { + const params = getStoredUtmParameters({ storageKey, storageType }) return params !== null && Object.keys(params).length > 0 } -/** - * Check if sessionStorage is available in the current environment - * - * @returns True if sessionStorage is available and functional - */ -export function isSessionStorageAvailable(): boolean { - return isStorageAvailable() -} - /** * Get the raw stored value without parsing or validation * Useful for debugging * * @param storageKey - Storage key to read (default: 'utm_parameters') + * @param storageType - Storage backend to read from (default: 'session') * @returns Raw string value or null */ -export function getRawStoredValue(storageKey: string = DEFAULT_STORAGE_KEY): string | null { - if (!isStorageAvailable()) { +export function getRawStoredValue( + storageKey: string = DEFAULT_STORAGE_KEY, + storageType: StorageType = 'session', +): string | null { + const storage = getStorageBackend(storageType) + if (!storage) { return null } try { - return sessionStorage.getItem(storageKey) + return storage.getItem(storageKey) } catch { return null } diff --git a/src/debug/docs.md b/src/debug/docs.md index 646a3b8..654a2fe 100644 --- a/src/debug/docs.md +++ b/src/debug/docs.md @@ -10,16 +10,16 @@ Path: @/src/debug ### How it fits into the larger codebase -- Imports `captureUtmParameters` from `@/src/core/capture`, `getStoredUtmParameters`/`isSessionStorageAvailable`/`getRawStoredValue` from `@/src/core/storage`, and `getDefaultConfig` from `@/src/config/defaults`. +- Imports `captureUtmParameters` from `@/src/core/capture`, `getStoredUtmParameters`/`isStorageAvailable`/`getRawStoredValue` from `@/src/core/storage`, and `getDefaultConfig` from `@/src/config/defaults`. - Re-exported through `@/src/index.ts` so consumers can call these functions directly. - Does not depend on or interact with `@/src/react` -- it operates on the core layer only. - All functions accept an optional `ResolvedUtmConfig`; when omitted, they fall back to `getDefaultConfig()`. ### Core Implementation -- `getDiagnostics()` assembles a `DiagnosticInfo` snapshot: resolves config, captures URL params via `captureUtmParameters`, reads stored params via `getStoredUtmParameters`, and checks `isSessionStorageAvailable()`. SSR-safe (returns empty URL and empty params when `window` is unavailable). +- `getDiagnostics()` assembles a `DiagnosticInfo` snapshot: resolves config, captures URL params via `captureUtmParameters`, reads stored params via `getStoredUtmParameters` (passing `storageType` from config), and checks `isStorageAvailable(config.storageType)`. SSR-safe (returns empty URL and empty params when `window` is unavailable). - `debugUtmState()` calls `getDiagnostics()` and formats output using `console.group`/`console.table` for structured browser console display. -- `checkUtmTracking()` calls `getDiagnostics()` and returns an array of status strings with emoji prefixes indicating state (e.g., whether params are in the URL, in storage, or if there is a mismatch suggesting the hook has not initialized yet). +- `checkUtmTracking()` calls `getDiagnostics()` and returns an array of status strings with emoji prefixes indicating state (e.g., whether params are in the URL, in storage, or if there is a mismatch suggesting the hook has not initialized yet). The storage-unavailable warning message dynamically uses `localStorage` or `sessionStorage` based on `config.storageType`. - `installDebugHelpers()` checks for `?debug_utm=true` in the URL query string. If present, it attaches a `window.utmDebug` object with `state()`, `check()`, `diagnostics()`, and `raw()` methods. Only activates in browser environments. ### Things to Know diff --git a/src/debug/index.ts b/src/debug/index.ts index 6bca290..7ba0834 100644 --- a/src/debug/index.ts +++ b/src/debug/index.ts @@ -8,11 +8,7 @@ import type { DiagnosticInfo, ResolvedUtmConfig } from '../types' import { captureUtmParameters } from '../core/capture' -import { - getStoredUtmParameters, - isSessionStorageAvailable, - getRawStoredValue, -} from '../core/storage' +import { getStoredUtmParameters, isStorageAvailable, getRawStoredValue } from '../core/storage' import { getDefaultConfig } from '../config/defaults' /** @@ -47,6 +43,7 @@ export function getDiagnostics(config?: ResolvedUtmConfig): DiagnosticInfo { const storedParams = getStoredUtmParameters({ storageKey: resolvedConfig.storageKey, keyFormat: resolvedConfig.keyFormat, + storageType: resolvedConfig.storageType, }) return { @@ -56,7 +53,7 @@ export function getDiagnostics(config?: ResolvedUtmConfig): DiagnosticInfo { urlParams, storedParams, storageKey: resolvedConfig.storageKey, - storageAvailable: isSessionStorageAvailable(), + storageAvailable: isStorageAvailable(resolvedConfig.storageType), } } @@ -77,6 +74,7 @@ export function debugUtmState(config?: ResolvedUtmConfig): void { console.group('📊 UTM Toolkit Debug Info') console.log('Enabled:', diagnostics.enabled) console.log('Key Format:', diagnostics.config.keyFormat) + console.log('Storage Type:', diagnostics.config.storageType) console.log('Storage Key:', diagnostics.storageKey) console.log('Storage Available:', diagnostics.storageAvailable) console.log('Current URL:', diagnostics.currentUrl) @@ -133,7 +131,9 @@ export function checkUtmTracking(config?: ResolvedUtmConfig): string[] { } if (!diagnostics.storageAvailable) { - messages.push('⚠️ sessionStorage is not available (private browsing or SSR?)') + const storageLabel = + diagnostics.config.storageType === 'local' ? 'localStorage' : 'sessionStorage' + messages.push(`⚠️ ${storageLabel} is not available (private browsing or SSR?)`) } const urlParamCount = Object.keys(diagnostics.urlParams).length @@ -212,8 +212,9 @@ export function installDebugHelpers(config?: ResolvedUtmConfig): void { /** Get raw storage value */ raw: (storageKey?: string) => { const key = storageKey || config?.storageKey || 'utm_parameters' - const raw = getRawStoredValue(key) - console.log(`Raw storage value for "${key}":`, raw) + const storageType = config?.storageType || 'session' + const raw = getRawStoredValue(key, storageType) + console.log(`Raw storage value for "${key}" (${storageType}):`, raw) return raw }, } diff --git a/src/docs.md b/src/docs.md index 1dec067..705b543 100644 --- a/src/docs.md +++ b/src/docs.md @@ -32,17 +32,17 @@ Consumer API - **types/** (`@/src/types`): Shared type definitions consumed by all other modules. Defines the dual key format system (snake_case/camelCase) and configuration interfaces. - **config/** (`@/src/config`): Pure configuration creation and validation. Merges partial user config with defaults to produce `ResolvedUtmConfig`. -- **core/** (`@/src/core`): Framework-agnostic UTM operations -- capture from URLs, sanitize parameter values, filter PII, persist in sessionStorage, append to outbound URLs, convert key formats, validate URLs. All SSR-safe. +- **core/** (`@/src/core`): Framework-agnostic UTM operations -- capture from URLs, sanitize parameter values, filter PII, persist in sessionStorage or localStorage (with optional TTL), append to outbound URLs, convert key formats, validate URLs. All SSR-safe. - **debug/** (`@/src/debug`): Development-time diagnostics. Assembles state snapshots and provides formatted console output and optional `window.utmDebug` helpers. - **react/** (`@/src/react`): React hook and context provider that orchestrate the core modules into stateful React APIs with auto-capture-on-mount behavior. -**Key data flow**: URL with UTM params --> `capture` (with optional sanitization and PII filtering) --> `store` in sessionStorage --> `appendToUrl` for outbound link generation. +**Key data flow**: URL with UTM params --> `capture` (with optional sanitization and PII filtering) --> `store` in sessionStorage or localStorage (with optional TTL) --> `appendToUrl` for outbound link generation. ### Things to Know - **Dual key format invariant**: The library supports both `snake_case` (URL convention) and `camelCase` (TypeScript convention) throughout, but all URL-facing operations always convert to snake_case internally. This is enforced in `@/src/core/appender.ts`. - **SSR safety**: Every module that touches browser APIs (`window`, `sessionStorage`, `URL`, `document`) guards against their absence. The library can be imported and initialized on the server without errors. - **Two entry points**: The package.json `exports` map defines separate conditional exports for `.` and `./react`, each with ESM/CJS/types variants. React is externalized in the build so it is not bundled into the output. -- **No runtime dependencies**: The library is self-contained. All functionality is implemented from scratch using standard Web APIs (`URL`, `URLSearchParams`, `sessionStorage`). +- **No runtime dependencies**: The library is self-contained. All functionality is implemented from scratch using standard Web APIs (`URL`, `URLSearchParams`, `sessionStorage`, `localStorage`). Created and maintained by Nori. diff --git a/src/index.ts b/src/index.ts index 7f6f9bb..c23e9f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,9 @@ export { getStoredUtmParameters, clearStoredUtmParameters, hasStoredUtmParameters, + isStorageAvailable, isSessionStorageAvailable, + isLocalStorageAvailable, getRawStoredValue, DEFAULT_STORAGE_KEY, type StorageOptions, @@ -90,6 +92,7 @@ export { getDiagnostics, debugUtmState, checkUtmTracking, installDebugHelpers } // Types export type { KeyFormat, + StorageType, SnakeCaseUtmKey, StandardSnakeCaseUtmKey, StandardCamelCaseUtmKey, diff --git a/src/react/docs.md b/src/react/docs.md index 3eb82cc..492a2a1 100644 --- a/src/react/docs.md +++ b/src/react/docs.md @@ -10,7 +10,7 @@ Path: @/src/react ### How it fits into the larger codebase -- `useUtmTracking` is the primary orchestrator: it calls `createConfig()` from `@/src/config`, then uses `captureUtmParameters`, `storeUtmParameters`, `getStoredUtmParameters`, `clearStoredUtmParameters`, `appendUtmParameters`, `convertParams`, and `isSnakeCaseUtmKey` from `@/src/core`. +- `useUtmTracking` is the primary orchestrator: it calls `createConfig()` from `@/src/config`, then uses `captureUtmParameters`, `storeUtmParameters`, `getStoredUtmParameters`, `clearStoredUtmParameters`, `appendUtmParameters`, `convertParams`, and `isSnakeCaseUtmKey` from `@/src/core`. It forwards `storageType` and `ttl` from config to all storage operations. - `UtmProvider` wraps `useUtmTracking` in a React context, enabling tree-wide access via `useUtmContext()`. - React is externalized in the build (`tsup.config.ts` declares `external: ['react']`) and declared as an optional peer dependency. The core library works without React. - Types (`UseUtmTrackingReturn`, `UtmProviderProps`, etc.) come from `@/src/types`. @@ -23,17 +23,17 @@ Path: @/src/react Mount | v -useState initializer --> getStoredUtmParameters() --> initial state from sessionStorage +useState initializer --> getStoredUtmParameters({storageType}) --> initial state from storage | v useEffect (once, via ref guard) --> if captureOnMount && enabled: | v capture() --> captureUtmParameters(window.location.href, {sanitize, piiFiltering}) --> if has params: - | storeUtmParameters() + | storeUtmParameters({storageType, ttl}) | setUtmParameters() | else if has defaultParams: - | store & set defaults + | store & set defaults (with storageType, ttl) v appendToUrl(url, platform?) --> merges: captured params < default share context < platform context | --> filters out excludeFromShares @@ -53,6 +53,7 @@ URL with UTM params - **Config is frozen at mount**: The `useRef` pattern means the resolved config never changes. If a consumer passes new config props, they will be ignored after the first render. - **Initialization guard**: `hasInitialized.current` is a ref (not state), so the guard works correctly across strict mode double-effects without triggering re-renders. - **`appendToUrl` exclusion logic**: The `excludeFromShares` filter converts camelCase keys to snake_case using inline regex (not the `toSnakeCase` utility), so it duplicates some conversion logic from `@/src/core/keys.ts`. +- **Storage options forwarding**: The hook passes `storageType` and `ttl` from the resolved config to `storeUtmParameters`, `getStoredUtmParameters`, and `clearStoredUtmParameters`. The `clear` callback passes `storageType` so it clears the correct backend. - **SSR safety**: The `useState` initializer checks `typeof window === 'undefined'` and returns `null` for server rendering. The `capture` callback also checks before accessing `window.location`. Created and maintained by Nori. diff --git a/src/react/useUtmTracking.ts b/src/react/useUtmTracking.ts index 9e7c88e..684d67b 100644 --- a/src/react/useUtmTracking.ts +++ b/src/react/useUtmTracking.ts @@ -98,6 +98,7 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack const stored = getStoredUtmParameters({ storageKey: config.storageKey, keyFormat: config.keyFormat, + storageType: config.storageType, }) return stored } @@ -130,6 +131,8 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack storeUtmParameters(params, { storageKey: config.storageKey, keyFormat: config.keyFormat, + storageType: config.storageType, + ttl: config.ttl, }) setUtmParameters(params) } else if (checkHasParams(config.defaultParams)) { @@ -138,6 +141,8 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack storeUtmParameters(defaultParams, { storageKey: config.storageKey, keyFormat: config.keyFormat, + storageType: config.storageType, + ttl: config.ttl, }) setUtmParameters(defaultParams) } @@ -147,9 +152,9 @@ export function useUtmTracking(options: UseUtmTrackingOptions = {}): UseUtmTrack * Clear stored UTM parameters */ const clear = useCallback(() => { - clearStoredUtmParameters(config.storageKey) + clearStoredUtmParameters(config.storageKey, config.storageType) setUtmParameters(null) - }, [config.storageKey]) + }, [config.storageKey, config.storageType]) /** * Append UTM parameters to a URL diff --git a/src/types/docs.md b/src/types/docs.md index dbbea8a..ee7517d 100644 --- a/src/types/docs.md +++ b/src/types/docs.md @@ -18,9 +18,9 @@ Path: @/src/types ### Core Implementation -- `KeyFormat` is a string literal union (`'snake_case' | 'camelCase'`) that controls key conversion throughout the library. +- `KeyFormat` is a string literal union (`'snake_case' | 'camelCase'`) that controls key conversion throughout the library. `StorageType` is a string literal union (`'session' | 'local'`) that controls which browser storage backend is used. - `UtmParametersSnake` uses an index signature `[key: \`utm_${string}\`]` to accept arbitrary `utm_*` keys while also declaring the standard ones explicitly. `UtmParametersCamel` uses a broader `[key: string]` index signature since TypeScript template literals cannot express the camelCase pattern. -- `ResolvedUtmConfig` mirrors `UtmConfig` but with all fields required -- it represents the result of merging user-provided partial config with defaults. +- `ResolvedUtmConfig` mirrors `UtmConfig` but with all fields required (except `ttl`, which remains optional) -- it represents the result of merging user-provided partial config with defaults. Both `UtmConfig` and `ResolvedUtmConfig` include `storageType` (defaulting to `'session'`) and an optional `ttl` (milliseconds, only meaningful for localStorage). - `ShareContextParams` uses `Partial>` with a `default` key for base params and platform-specific overrides, enabling a layered merge strategy in `useUtmTracking`'s `appendToUrl` callback. - `AppendOptions` controls whether UTM params go into query string or fragment, and whether existing UTM params on the target URL are preserved. - `SanitizeConfig` defines value sanitization behavior with fields for `enabled`, `stripHtml`, `stripControlChars`, `maxLength`, and an optional `customPattern` (RegExp). It appears as `Partial` on `UtmConfig` (user input) and as a required `SanitizeConfig` on `ResolvedUtmConfig` (resolved output). This follows the same partial-in/resolved-out pattern used by the rest of the config system. diff --git a/src/types/index.ts b/src/types/index.ts index 0c01414..221acaf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,13 @@ */ export type KeyFormat = 'snake_case' | 'camelCase' +/** + * Storage backend options + * - 'session': sessionStorage (cleared when tab/browser closes) + * - 'local': localStorage (persists across sessions, optionally with TTL) + */ +export type StorageType = 'session' | 'local' + /** * Standard UTM parameter keys in snake_case (URL format) */ @@ -175,9 +182,15 @@ export interface UtmConfig { /** Key format for returned UTM parameters (default: 'snake_case') */ keyFormat?: KeyFormat - /** Storage key prefix for sessionStorage (default: 'utm_parameters') */ + /** Storage key for browser storage (default: 'utm_parameters') */ storageKey?: string + /** Storage backend: 'session' for sessionStorage, 'local' for localStorage (default: 'session') */ + storageType?: StorageType + + /** Time-to-live in milliseconds for stored parameters (only applies to localStorage) */ + ttl?: number + /** Auto-capture UTM params on React hook mount (default: true) */ captureOnMount?: boolean @@ -213,6 +226,8 @@ export interface ResolvedUtmConfig { enabled: boolean keyFormat: KeyFormat storageKey: string + storageType: StorageType + ttl?: number captureOnMount: boolean appendToShares: boolean allowedParameters: string[] @@ -284,6 +299,6 @@ export interface DiagnosticInfo { /** Storage key being used */ storageKey: string - /** Whether sessionStorage is available */ + /** Whether the configured storage backend is available */ storageAvailable: boolean } From e64eff62390db183e2c5d094292e724d11cc6a9c Mon Sep 17 00:00:00 2001 From: Jack Misner Date: Fri, 13 Feb 2026 20:25:40 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20use=20explicit=20type=20check=20for?= =?UTF-8?q?=20TTL=20to=20avoid=20truthy=20edge=20case=20with=20zero=20?= =?UTF-8?q?=F0=9F=A4=96=20Generated=20with=20[Nori](https://nori.ai)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Nori --- src/core/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/storage.ts b/src/core/storage.ts index 2fde416..a9b5e3c 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -190,7 +190,7 @@ export function storeUtmParameters(params: UtmParameters, options: StorageOption // Build envelope — TTL only applies to localStorage const now = Date.now() - const eat = storageType === 'local' && ttl ? now + ttl : null + const eat = storageType === 'local' && typeof ttl === 'number' && ttl > 0 ? now + ttl : null const envelope: StoredUtmEnvelope = { params: paramsToStore, iat: now, From 977ed69e0471b435ffafc0d98161257465415497 Mon Sep 17 00:00:00 2001 From: Jack Misner Date: Fri, 13 Feb 2026 20:58:47 +0000 Subject: [PATCH 3/3] fix: enhance envelope format validation to include 'eat' property check --- src/core/storage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/storage.ts b/src/core/storage.ts index a9b5e3c..c5d2b3d 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -103,7 +103,9 @@ function isEnvelopeFormat(data: unknown): data is StoredUtmEnvelope { typeof obj.params === 'object' && obj.params !== null && 'iat' in obj && - typeof obj.iat === 'number' + typeof obj.iat === 'number' && + 'eat' in obj && + (obj.eat === null || typeof obj.eat === 'number') ) }