persista
v0.2.0
Published
A powerful localStorage wrapper with expiration, encryption, events and more
Downloads
135
Maintainers
Readme
Persista
A powerful localStorage wrapper with expiration (TTL), AES-GCM encryption, events, storage monitoring, and smart cleanup — all with full type preservation for objects, arrays, Maps, Sets, and Dates.
Table of Contents
- Features
- Installation
- Quick Start
- Constructor Options
- API Reference
- Async vs Sync Methods
- Type Preservation
- Encryption
- Expiration (TTL)
- Events
- Storage Monitoring
- Smart Cleanup
- Error Handling
- TypeScript
- Changelog
Features
| Feature | Description |
|---|---|
| 🔄 Type preservation | Stores and restores number, boolean, object, array, Date, Map, Set |
| ⏰ Expiration (TTL) | Auto-expire items after a given number of milliseconds |
| 🔐 Encryption | Per-instance or per-item AES-GCM encryption via the Web Crypto API |
| 📡 Events | Subscribe to set, remove, clear, expired events |
| 📊 Storage monitoring | Track bytes used, percentage of quota, and remaining space |
| 🧹 Smart cleanup | Remove expired, old, or excess items in one call |
| 🏷️ Key prefixing | Isolate one instance from another within the same origin |
| 🐛 Debug mode | Verbose console logging during development |
| 🟦 TypeScript | Full type definitions included |
Installation
npm install persistaOr via CDN (UMD build):
<script src="https://unpkg.com/persista/dist/persista.min.js"></script>
<script>
const storage = new Persista({ prefix: 'myapp' });
</script>Quick Start
import Persista from 'persista';
const storage = new Persista({ prefix: 'myapp' });
// Store any value
await storage.set('user', { name: 'Alice', role: 'admin' });
// Retrieve it
const user = await storage.get('user');
console.log(user.name); // 'Alice'
// Store with a 1-hour TTL
await storage.set('session', { token: 'abc123' }, { expires: 60 * 60 * 1000 });
// Check existence
if (storage.has('user')) {
console.log('user is in storage');
}
// Remove
storage.remove('user');Constructor Options
const storage = new Persista(options);| Option | Type | Default | Description |
|---|---|---|---|
| prefix | string | '' | Prepended to every key. Use this to namespace one instance from another. |
| separator | string | ':' | Character placed between the prefix and key name. |
| debug | boolean | false | Log every operation to the console. |
| encryption.key | string | undefined | When provided, all values are encrypted with AES-GCM by default. |
// Two isolated instances on the same page
const userStorage = new Persista({ prefix: 'user' });
const cacheStorage = new Persista({ prefix: 'cache' });
// With encryption enabled for all writes
const secureStorage = new Persista({
prefix: 'secure',
encryption: { key: 'my-secret-key-should-be-long' }
});
// Debug mode (logs every operation)
const devStorage = new Persista({ prefix: 'dev', debug: true });API Reference
set()
set(key: string, value: any, options?: SetOptions): Promise<boolean>Store any value under key. Always await this call.
Options:
| Option | Type | Description |
|---|---|---|
| expires | number | TTL in milliseconds. The item will return null after this time. |
| encrypt | boolean | Override the instance's encryption setting for this single call. |
// Basic
await storage.set('theme', 'dark');
// With 10-minute TTL
await storage.set('otp', '483920', { expires: 10 * 60 * 1000 });
// Force-disable encryption for this one item (even if instance has encryption on)
await storage.set('publicData', { visible: true }, { encrypt: false });Returns true on success. Throws a StorageError (or QuotaExceededError) on failure.
get()
get(key: string, defaultValue?: any): Promise<any>Retrieve a value by key. Always await this call.
- Returns
defaultValue(default:null) if the key does not exist or has expired. - Automatically decrypts if the item was stored with encryption.
- Automatically deletes expired items and fires the
'expired'event.
const theme = await storage.get('theme'); // 'dark' or null
const theme = await storage.get('theme', 'light'); // 'dark' or 'light'
const user = await storage.get('user');
if (user) {
console.log(user.name);
}remove()
remove(key: string): booleanDelete a single key. Synchronous.
storage.remove('session');Fires the 'remove' event with the key and its previous value.
clear()
clear(): booleanDelete all keys belonging to this instance's prefix. Keys from other Persista instances (with different prefixes) are unaffected. Synchronous.
storage.clear();Fires the 'clear' event with an array of the removed key names.
has()
has(key: string): booleanCheck whether a key exists in storage. Synchronous. Does not check expiry — use get() if you need expiry-aware existence checking.
if (storage.has('user')) {
// key exists (may still be expired)
}keys()
keys(): string[]Return all key names under this prefix, without the prefix. Synchronous.
await storage.set('a', 1);
await storage.set('b', 2);
console.log(storage.keys()); // ['a', 'b']all()
all(): Promise<Record<string, any>>Return every key-value pair as a plain object. Async (decryption may be needed).
await storage.set('x', 1);
await storage.set('y', 2);
const everything = await storage.all();
// { x: 1, y: 2 }count()
count(): numberReturn the number of keys stored under this prefix. Synchronous.
console.log(storage.count()); // 3getSize()
getSize(): numberReturn the estimated total bytes used by this instance (keys + values).
Implementation note: localStorage stores strings internally as UTF-16 (2 bytes per character) in most browser engines. Persista multiplies character length × 2, which is the standard estimation approach. Actual browser-internal accounting may vary slightly.
console.log(storage.getSize()); // e.g. 24576getUsage()
getUsage(quotaMax?: number): numberReturn usage as a percentage of the given quota (default: 5 MB).
console.log(storage.getUsage()); // e.g. 0.46 (percent of 5 MB)
console.log(storage.getUsage(2097152)); // percent of 2 MBgetRemainingSpace()
getRemainingSpace(quotaMax?: number): numberReturn estimated remaining bytes before quota is reached (default quota: 5 MB).
console.log(storage.getRemainingSpace()); // e.g. 5218304getInfo()
getInfo(key: string): ItemInfo | nullReturn detailed metadata about a single key. Synchronous.
interface ItemInfo {
key: string;
size: number; // bytes (key + value, UTF-16 estimate)
created: number; // Unix ms timestamp when item was stored
expires: number | null; // absolute Unix ms expiry timestamp, or null
valueType: string; // 'string' | 'number' | 'boolean' | 'object' | 'array'
hasExpired: boolean;
}await storage.set('token', 'abc', { expires: 3600000 });
const info = storage.getInfo('token');
// {
// key: 'token',
// size: 96,
// created: 1713000000000,
// expires: 1713003600000,
// valueType: 'string',
// hasExpired: false
// }Returns null if the key does not exist.
cleanup()
cleanup(options?: CleanupOptions): numberRemove items matching the given criteria. Returns the number of items removed. Synchronous.
interface CleanupOptions {
removeExpired?: boolean; // default: true — remove TTL-expired items
olderThan?: number; // remove items created more than N ms ago
keep?: number; // after other rules, keep only the N newest items
}// Remove only expired items (default behaviour)
storage.cleanup();
// Remove anything older than 7 days
storage.cleanup({ olderThan: 7 * 24 * 60 * 60 * 1000 });
// Keep at most 50 items (oldest removed first)
storage.cleanup({ keep: 50, removeExpired: false });
// All three rules at once
storage.cleanup({
removeExpired: true,
olderThan: 7 * 24 * 60 * 60 * 1000,
keep: 100
});on()
on(event: EventName, callback: (...args: any[]) => void): thisRegister an event listener. Returns this for chaining.
storage
.on('set', (key, value, options) => console.log('stored', key))
.on('remove', (key, value) => console.log('removed', key))
.on('clear', (keys) => console.log('cleared', keys))
.on('expired', (key, value) => console.log('expired', key));off()
off(event: EventName, callback?: (...args: any[]) => void): thisRemove an event listener. Omit callback to remove all listeners for that event.
const handler = (key) => console.log(key);
storage.on('remove', handler);
storage.off('remove', handler); // remove just this listener
storage.off('remove'); // remove ALL 'remove' listenersAsync vs Sync Methods
Persista mixes async and sync methods. The split is intentional:
Async (must be await-ed):
| Method | Why async |
|---|---|
| set() | May need to run AES-GCM encryption |
| get() | May need to run AES-GCM decryption |
| all() | Calls get() for every key |
Sync (no await needed):
| Method | Notes |
|---|---|
| remove() | Raw localStorage.removeItem |
| clear() | Raw localStorage.removeItem in a loop |
| has() | Raw localStorage.getItem check |
| keys() | Iterates localStorage |
| count() | Calls keys().length |
| getSize() | Iterates localStorage for byte count |
| getUsage() | Math on getSize() |
| getRemainingSpace() | Math on getSize() |
| getInfo() | Reads and parses a single item |
| cleanup() | Calls remove() in a loop |
Even without encryption enabled,
set()andget()remain async so that adding encryption later is a non-breaking change.
Type Preservation
Persista fully round-trips these types through localStorage:
await storage.set('num', 42);
await storage.set('bool', true);
await storage.set('obj', { nested: { value: 1 } });
await storage.set('arr', [1, 'two', { three: 3 }]);
await storage.set('date', new Date());
await storage.set('map', new Map([['a', 1], ['b', 2]]));
await storage.set('set', new Set([1, 2, 3]));
// All come back as their original types
const map = await storage.get('map'); // instanceof Map ✅
const set = await storage.get('set'); // instanceof Set ✅
const d = await storage.get('date'); // instanceof Date ✅Map and Set are serialised to a tagged object format internally so they survive JSON.stringify → JSON.parse. This is handled automatically — no extra steps required.
Encryption
Persista uses AES-GCM 256-bit encryption via the browser's built-in Web Crypto API. No external crypto libraries are needed.
Enable for all writes
const storage = new Persista({
prefix: 'vault',
encryption: { key: 'a-long-secret-key-you-should-keep-safe' }
});
await storage.set('creditCard', '4111-1111-1111-1111');
// Raw localStorage value is base64 ciphertext — unreadable without the keyDisable encryption for a single item
await storage.set('publicConfig', { theme: 'dark' }, { encrypt: false });How it works
Each encrypted item has a fresh random salt (16 bytes) and IV (12 bytes) generated at write time. This means:
- Two writes of the same value produce completely different ciphertexts.
- Even if an attacker captures the ciphertext, they cannot determine the original value without the key.
- The key itself is never stored — it lives only in your JavaScript and is used to derive the actual AES key via PBKDF2 (100,000 iterations, SHA-256).
Notes
- Changing the encryption key means existing items can no longer be decrypted. Plan key rotation carefully.
- If the Web Crypto API is unavailable (non-HTTPS, very old browser), Persista falls back to plain storage and logs a warning in debug mode.
Expiration (TTL)
Pass expires in milliseconds to set():
// Expires in 30 minutes
await storage.set('session', data, { expires: 30 * 60 * 1000 });
// Expires in 1 hour
await storage.set('cache', response, { expires: 3_600_000 });When get() is called on an expired item:
- The item is deleted from
localStorage. - The
'expired'event is fired. null(or yourdefaultValue) is returned.
Items are not proactively scanned — expiry is checked lazily on access. Use cleanup({ removeExpired: true }) to purge expired items without reading them.
Events
// 'set' — fired after every successful write
storage.on('set', (key, value, options) => {
console.log(`Stored "${key}"`);
});
// 'remove' — fired when a key is explicitly removed
storage.on('remove', (key, previousValue) => {
console.log(`Removed "${key}", had value:`, previousValue);
});
// 'clear' — fired when the entire instance is cleared
storage.on('clear', (removedKeys) => {
console.log(`Cleared ${removedKeys.length} keys`);
});
// 'expired' — fired when a TTL item is detected as expired during get()
storage.on('expired', (key, rawStoredValue) => {
console.log(`"${key}" expired and was removed`);
});Storage Monitoring
const storage = new Persista({ prefix: 'app' });
// Total bytes used by this instance
const bytes = storage.getSize();
console.log(`Using ${bytes} bytes`);
// Percentage of the 5 MB default quota
const pct = storage.getUsage();
console.log(`${pct.toFixed(2)}% full`);
// Warn before quota is exceeded
if (storage.getUsage() > 80) {
console.warn('Storage is over 80% — consider cleanup');
}
// Remaining bytes
const remaining = storage.getRemainingSpace();
console.log(`${remaining} bytes remaining`);
// Inspect a specific item
const info = storage.getInfo('user');
console.log(info);
// {
// key: 'user',
// size: 1024,
// created: 1713000000000,
// expires: null,
// valueType: 'object',
// hasExpired: false
// }Smart Cleanup
// Remove only expired items (default)
const removed = storage.cleanup();
console.log(`Removed ${removed} expired items`);
// Remove items older than 7 days
storage.cleanup({ olderThan: 7 * 24 * 60 * 60 * 1000 });
// Keep only the 100 most recently written items
storage.cleanup({ keep: 100 });
// Kitchen-sink cleanup before the user might hit quota
storage.cleanup({
removeExpired: true,
olderThan: 30 * 24 * 60 * 60 * 1000, // older than 30 days
keep: 200 // hard cap at 200 items
});Error Handling
Persista throws typed errors you can catch:
import Persista, { QuotaExceededError, StorageError } from 'persista';
try {
await storage.set('key', hugePayload);
} catch (err) {
if (err instanceof QuotaExceededError) {
// localStorage quota exceeded — run cleanup or alert the user
storage.cleanup({ removeExpired: true, olderThan: 7 * 24 * 60 * 60 * 1000 });
} else if (err instanceof StorageError) {
console.error('Storage error:', err.message);
}
}| Error class | When thrown |
|---|---|
| QuotaExceededError | localStorage quota is exceeded on set() |
| StorageError | Any other localStorage or encryption failure |
TypeScript
Types are bundled. Import like any other module:
import Persista, { PersistaOptions, SetOptions, ItemInfo } from 'persista';
const storage = new Persista({ prefix: 'app' });
// Generic type parameter for get()
const user = await storage.get<{ name: string; role: string }>('user');
user?.name; // typed as string | undefinedChangelog
[0.2.0]
- Added AES-GCM encryption (
encryption.keyoption, per-itemencryptoverride) - Added Storage monitoring:
getSize(),getUsage(),getRemainingSpace() - Added Item metadata:
getInfo()with creation time, expiry, size, type - Added Smart cleanup:
cleanup()with age, count, and expiry rules - Added Full
Map,Set, andDateround-trip preservation throughlocalStorage - Changed
set()andget()are nowasync(required for encryption support) - Changed
all()is nowasync - Fixed
clear()no longer makes two separate passes overlocalStorage - Fixed Salt encoding bug in AES-GCM key derivation
[0.1.0]
- Initial release
- Basic CRUD with type preservation
- Expiration (TTL) support
- Event system (
on,off) - Key prefixing
- Debug mode
