ng7-storage
Version:
An Angular service for browser session storage management with optional base64 encryption/decryption.
879 lines (872 loc) • 30.2 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, signal, computed, Optional, Inject, Injectable } from '@angular/core';
import { Subject, BehaviorSubject, distinctUntilChanged, filter, map } from 'rxjs';
// Injection Tokens
const STORAGE_OPTIONS = new InjectionToken('StorageOptions');
const STORAGE_NAMED_OPTIONS = new InjectionToken('StorageNamedOptions');
const STORAGE_FLAGS = new InjectionToken('StorageFlags');
// Default Configuration
const DEFAULT_STORAGE_CONFIG = {
prefix: 'ng-storage',
defaultTTL: 0,
enableLogging: false,
caseSensitive: false,
storageType: 'sessionStorage',
};
const DEFAULT_STORAGE_FLAGS = {
autoCleanup: true,
strictMode: false,
enableMetrics: false,
};
function provideNgStorage(optionsFactory, flags = {}) {
return [
NgStorageService,
{
provide: STORAGE_OPTIONS,
useFactory: optionsFactory,
},
{
provide: STORAGE_FLAGS,
useValue: { ...DEFAULT_STORAGE_FLAGS, ...flags },
},
];
}
/**
* Provides multiple named NgStorageService instances
*/
function provideNamedNgStorage(optionsFactory, flags = {}) {
return [
{
provide: STORAGE_NAMED_OPTIONS,
useFactory: optionsFactory,
},
{
provide: STORAGE_FLAGS,
useValue: { ...DEFAULT_STORAGE_FLAGS, ...flags },
},
];
}
class CryptoUtils {
static { this.ALGORITHM = 'AES-GCM'; }
static { this.KEY_LENGTH = 256; }
static { this.IV_LENGTH = 12; } // 96 bits for GCM
static { this.TAG_LENGTH = 128; } // 128 bits authentication tag
static { this.encryptionKey = null; }
static { this.keyPromise = null; }
/**
* Derives a consistent key from a password using PBKDF2
*/
static async deriveKey(password, salt) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']);
return crypto.subtle.deriveKey({
name: 'PBKDF2',
salt: salt,
iterations: 100000, // OWASP recommended minimum
hash: 'SHA-256',
}, keyMaterial, {
name: this.ALGORITHM,
length: this.KEY_LENGTH,
}, false, ['encrypt', 'decrypt']);
}
/**
* Gets or creates the encryption key
*/
static async getEncryptionKey() {
if (this.encryptionKey) {
return this.encryptionKey;
}
if (this.keyPromise) {
return this.keyPromise;
}
this.keyPromise = this.createEncryptionKey();
this.encryptionKey = await this.keyPromise;
return this.encryptionKey;
}
/**
* Creates a new encryption key or derives from stored salt
*/
static async createEncryptionKey() {
const keyIdentifier = '__ng_storage_key_salt__';
let salt;
// Try to get existing salt from localStorage
try {
const existingSalt = localStorage.getItem(keyIdentifier);
if (existingSalt) {
salt = new Uint8Array(JSON.parse(existingSalt));
}
else {
// Generate new salt
salt = crypto.getRandomValues(new Uint8Array(32));
localStorage.setItem(keyIdentifier, JSON.stringify(Array.from(salt)));
}
}
catch {
// Fallback to generated salt if localStorage is not available
salt = crypto.getRandomValues(new Uint8Array(32));
}
// Use a default password - in production, you might want to make this configurable
const password = 'ng-storage-default-key-2024';
return this.deriveKey(password, salt);
}
/**
* Encrypts data using AES-GCM
*/
static async encrypt(data) {
try {
const key = await this.getEncryptionKey();
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(this.IV_LENGTH));
// Encrypt the data
const encryptedBuffer = await crypto.subtle.encrypt({
name: this.ALGORITHM,
iv: iv,
tagLength: this.TAG_LENGTH,
}, key, dataBuffer);
// Combine IV and encrypted data
const encryptedArray = new Uint8Array(encryptedBuffer);
const combined = new Uint8Array(iv.length + encryptedArray.length);
combined.set(iv);
combined.set(encryptedArray, iv.length);
// Convert to base64 for storage
return btoa(String.fromCharCode(...combined));
}
catch (error) {
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Decrypts data using AES-GCM
*/
static async decrypt(encryptedData) {
try {
const key = await this.getEncryptionKey();
// Convert from base64
const combined = new Uint8Array(atob(encryptedData)
.split('')
.map((char) => char.charCodeAt(0)));
// Extract IV and encrypted data
const iv = combined.slice(0, this.IV_LENGTH);
const encryptedBuffer = combined.slice(this.IV_LENGTH);
// Decrypt the data
const decryptedBuffer = await crypto.subtle.decrypt({
name: this.ALGORITHM,
iv: iv,
tagLength: this.TAG_LENGTH,
}, key, encryptedBuffer);
// Convert back to string
const decoder = new TextDecoder();
return decoder.decode(decryptedBuffer);
}
catch (error) {
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Checks if Web Crypto API is available
*/
static isSupported() {
return (typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined' &&
typeof crypto.subtle.encrypt === 'function');
}
/**
* Clears the cached encryption key (useful for testing or key rotation)
*/
static clearKey() {
this.encryptionKey = null;
this.keyPromise = null;
}
}
/**
* Simple provider for basic configuration
*/
function provideNgStorageConfig(config, flags = {}) {
return provideNgStorage(() => config, flags);
}
class NgStorageService {
constructor(options, flags) {
// Reactive state management
this._storageData = signal({});
this._changeSubject = new Subject();
this._keyChangeSubjects = new Map();
// Public reactive properties
this.storageData = this._storageData.asReadonly();
this.changes$ = this._changeSubject.asObservable();
// Computed stats
this.stats = computed(() => {
const data = this._storageData();
return {
itemCount: Object.keys(data).length,
isEmpty: Object.keys(data).length === 0,
keys: Object.keys(data),
};
});
// Merge configurations
this.config = {
...DEFAULT_STORAGE_CONFIG,
...options,
};
this.flags = {
...DEFAULT_STORAGE_FLAGS,
...flags,
};
// Set storage type and messages
this.storageTypeName =
this.config.storageType === 'localStorage'
? 'local storage'
: 'session storage';
this.supportedMessage = `Your browser doesn't support ${this.storageTypeName}. Please update your browser.`;
// Get the appropriate storage instance
this.storage = this.getStorageInstance();
// Check if storage is supported
this.isSupported = this.checkStorageSupport();
this.isCryptoSupported = CryptoUtils.isSupported();
if (!this.isSupported) {
console.error(this.supportedMessage);
if (this.flags.strictMode) {
throw new Error(this.supportedMessage);
}
}
if (!this.isCryptoSupported) {
console.warn('[NgStorageService] Web Crypto API not supported. Encryption will be disabled.');
}
// Initialize reactive state
this.initializeReactiveState();
// Clean expired items on initialization
if (this.flags.autoCleanup) {
this.cleanupExpiredItems();
}
// Set up periodic cleanup
if (typeof window !== 'undefined' && this.flags.autoCleanup) {
setInterval(() => this.cleanupExpiredItems(), 5 * 60 * 1000); // Every 5 minutes
}
}
/**
* Gets the appropriate storage instance based on configuration
*/
getStorageInstance() {
if (typeof window === 'undefined') {
throw new Error('Storage is not available in server-side rendering');
}
switch (this.config.storageType) {
case 'localStorage':
return window.localStorage;
case 'sessionStorage':
return window.sessionStorage;
default:
throw new Error(`Unsupported storage type: ${this.config.storageType}`);
}
}
/**
* Checks if the configured storage is supported and available
*/
checkStorageSupport() {
try {
if (typeof window === 'undefined') {
return false;
}
const testKey = '__ng_storage_test__';
this.storage.setItem(testKey, 'test');
this.storage.removeItem(testKey);
return true;
}
catch (error) {
return false;
}
}
/**
* Initializes reactive state from existing storage
*/
async initializeReactiveState() {
try {
const data = {};
const prefix = `${this.config.prefix}:`;
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key?.startsWith(prefix)) {
const originalKey = key.substring(prefix.length);
try {
const value = await this.getDataInternal(originalKey);
if (value !== null) {
data[originalKey] = value;
}
}
catch (error) {
this.log('Failed to load key during initialization', originalKey, error);
}
}
}
this._storageData.set(data);
}
catch (error) {
this.log('Failed to initialize reactive state', '', error);
}
}
/**
* Generates a storage key with prefix
*/
generateKey(key) {
if (!key || typeof key !== 'string') {
throw new Error('Storage key must be a non-empty string');
}
const processedKey = this.config.caseSensitive ? key : key.toLowerCase();
return `${this.config.prefix}:${processedKey}`;
}
/**
* Logs debug information if logging is enabled
*/
log(action, key, data) {
if (this.config.enableLogging) {
console.log(`[NgStorageService] ${action}:`, { key, data });
}
}
/**
* Encrypts data using AES-GCM encryption
*/
async encrypt(data) {
if (!this.isCryptoSupported) {
this.log('Crypto not supported, falling back to base64 encoding', '', null);
return btoa(encodeURIComponent(data));
}
try {
return await CryptoUtils.encrypt(data);
}
catch (error) {
this.log('Encryption failed, falling back to base64 encoding', '', error);
return btoa(encodeURIComponent(data));
}
}
/**
* Decrypts data using AES-GCM decryption
*/
async decrypt(encryptedData) {
if (!this.isCryptoSupported) {
try {
return decodeURIComponent(atob(encryptedData));
}
catch (error) {
this.log('Base64 decoding failed', '', error);
throw new Error('Failed to decode data');
}
}
try {
return await CryptoUtils.decrypt(encryptedData);
}
catch (error) {
// Fallback to base64 decoding for backward compatibility
try {
return decodeURIComponent(atob(encryptedData));
}
catch (fallbackError) {
this.log('Both decryption methods failed', '', {
error,
fallbackError,
});
throw new Error('Failed to decrypt data');
}
}
}
/**
* Calculates the expiry timestamp
*/
calculateExpiry(ttlMinutes) {
const ttl = ttlMinutes ?? this.config.defaultTTL;
return ttl > 0 ? Date.now() + ttl * 60 * 1000 : undefined;
}
/**
* Checks if an item has expired
*/
isExpired(item) {
return item.expiry ? Date.now() > item.expiry : false;
}
/**
* Emits change event
*/
emitChange(key, oldValue, newValue, action) {
const event = {
key,
oldValue,
newValue,
action,
timestamp: Date.now(),
};
this._changeSubject.next(event);
// Update key-specific subject
const keySubject = this._keyChangeSubjects.get(key);
if (keySubject) {
keySubject.next(newValue);
}
this.log('Change emitted', key, event);
}
/**
* Updates reactive state
*/
updateReactiveState(key, value, action) {
const currentData = this._storageData();
const oldValue = currentData[key] ?? null;
if (action === 'remove' || action === 'clear' || action === 'expire') {
const newData = { ...currentData };
delete newData[key];
this._storageData.set(newData);
this.emitChange(key, oldValue, null, action);
}
else {
this._storageData.update((data) => ({ ...data, [key]: value }));
this.emitChange(key, oldValue, value, action);
}
}
/**
* Internal get data method without reactive updates
*/
async getDataInternal(key, options = {}) {
try {
const storageKey = this.generateKey(key);
const { decrypt = false, defaultValue = null } = options;
const rawData = this.storage.getItem(storageKey);
if (!rawData) {
return defaultValue;
}
let parsedData;
if (decrypt) {
parsedData = await this.decrypt(rawData);
}
else {
parsedData = rawData;
}
const item = JSON.parse(parsedData);
// Check if item has expired
if (this.isExpired(item)) {
this.storage.removeItem(storageKey);
return defaultValue;
}
return item.value;
}
catch (error) {
this.log('Get data failed', key, error);
return options.defaultValue ?? null;
}
}
/**
* Removes expired items from storage
*/
async cleanupExpiredItems() {
try {
const keysToRemove = [];
const currentData = this._storageData();
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key?.startsWith(`${this.config.prefix}:`)) {
try {
const rawData = this.storage.getItem(key);
if (rawData) {
let parsedData = rawData;
// Try to detect if encrypted
try {
JSON.parse(parsedData);
}
catch {
try {
parsedData = await this.decrypt(rawData);
}
catch {
keysToRemove.push(key);
continue;
}
}
const item = JSON.parse(parsedData);
if (this.isExpired(item)) {
keysToRemove.push(key);
}
}
}
catch (error) {
keysToRemove.push(key);
}
}
}
keysToRemove.forEach((storageKey) => {
const originalKey = storageKey.substring(`${this.config.prefix}:`.length);
this.storage.removeItem(storageKey);
if (currentData[originalKey] !== undefined) {
this.updateReactiveState(originalKey, null, 'expire');
}
this.log('Removed expired item', originalKey);
});
}
catch (error) {
this.log('Cleanup failed', '', error);
}
}
/**
* Stores data in storage with optional encryption and TTL
*/
async setData(key, value, options = {}) {
try {
if (!this.isSupported) {
throw new Error(this.supportedMessage);
}
const storageKey = this.generateKey(key);
const { encrypt = false, ttlMinutes } = options;
const item = {
value,
timestamp: Date.now(),
expiry: this.calculateExpiry(ttlMinutes),
encrypted: encrypt,
};
let serializedData = JSON.stringify(item);
if (encrypt) {
serializedData = await this.encrypt(serializedData);
}
this.storage.setItem(storageKey, serializedData);
this.updateReactiveState(key, value, 'set');
this.log('Data stored', key, { value, options });
return true;
}
catch (error) {
this.log('Set data failed', key, error);
if (error instanceof Error && error.name === 'QuotaExceededError') {
throw new Error('Storage quota exceeded. Try clearing some data.');
}
throw error;
}
}
/**
* Retrieves data from storage with automatic decryption and expiry check
*/
async getData(key, options = {}) {
// Try to get from reactive state first
const reactiveValue = this._storageData()[key];
if (reactiveValue !== undefined) {
return reactiveValue;
}
// Fallback to storage
const value = await this.getDataInternal(key, options);
// Update reactive state if value found
if (value !== null) {
this._storageData.update((data) => ({ ...data, [key]: value }));
}
return value;
}
/**
* Creates a signal for a specific key
*/
async createSignal(key, defaultValue) {
const initialValue = (await this.getData(key)) ?? defaultValue ?? null;
const keySignal = signal(initialValue);
// Subscribe to changes for this key
this.watch(key).subscribe((value) => {
keySignal.set(value);
});
return keySignal.asReadonly();
}
/**
* Watch changes for a specific key
*/
watch(key) {
if (!this._keyChangeSubjects.has(key)) {
// Initialize with current value asynchronously
this.getData(key).then((currentValue) => {
if (!this._keyChangeSubjects.has(key)) {
this._keyChangeSubjects.set(key, new BehaviorSubject(currentValue));
}
});
// Create with null initially
this._keyChangeSubjects.set(key, new BehaviorSubject(null));
}
return this._keyChangeSubjects
.get(key)
.asObservable()
.pipe(distinctUntilChanged());
}
/**
* Watch all changes
*/
watchAll() {
return this.changes$;
}
/**
* Watch changes for multiple keys
*/
watchKeys(keys) {
return this.changes$.pipe(filter((event) => keys.includes(event.key)), map((event) => ({ key: event.key, value: event.newValue })));
}
/**
* Watch changes by pattern (simple glob-like matching)
*/
watchPattern(pattern) {
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
return this.changes$.pipe(filter((event) => regex.test(event.key)), map((event) => ({ key: event.key, value: event.newValue })));
}
/**
* Removes data associated with the specified key
*/
removeData(key) {
try {
if (!this.isSupported) {
throw new Error(this.supportedMessage);
}
const storageKey = this.generateKey(key);
this.storage.removeItem(storageKey);
this.updateReactiveState(key, null, 'remove');
this.log('Data removed', key);
return true;
}
catch (error) {
this.log('Remove data failed', key, error);
return false;
}
}
/**
* Removes multiple items at once
*/
removeMultiple(keys) {
const result = { success: [], failed: [] };
keys.forEach((key) => {
if (this.removeData(key)) {
result.success.push(key);
}
else {
result.failed.push(key);
}
});
return result;
}
/**
* Clears all storage data with the current prefix
*/
removeAll() {
try {
if (!this.isSupported) {
throw new Error(this.supportedMessage);
}
const keysToRemove = [];
const currentData = this._storageData();
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key?.startsWith(`${this.config.prefix}:`)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => this.storage.removeItem(key));
// Clear reactive state and emit events
Object.keys(currentData).forEach((key) => {
this.updateReactiveState(key, null, 'clear');
});
this.log('All data cleared', '', { removedCount: keysToRemove.length });
return true;
}
catch (error) {
this.log('Clear all failed', '', error);
return false;
}
}
/**
* Checks if a key exists in storage
*/
async hasKey(key) {
// Check reactive state first
if (this._storageData()[key] !== undefined) {
return true;
}
// Check storage
try {
if (!this.isSupported) {
return false;
}
const value = await this.getDataInternal(key);
const exists = value !== null;
// Update reactive state if found
if (exists) {
this._storageData.update((data) => ({ ...data, [key]: value }));
}
return exists;
}
catch (error) {
this.log('Has key check failed', key, error);
return false;
}
}
/**
* Gets all keys managed by this service
*/
getKeys() {
return Object.keys(this._storageData());
}
/**
* Gets storage statistics
*/
async getStorageStats() {
const stats = {
totalItems: 0,
totalSize: 0,
availableSpace: 0,
items: [],
};
try {
if (!this.isSupported) {
return stats;
}
const prefix = `${this.config.prefix}:`;
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key?.startsWith(prefix)) {
const data = this.storage.getItem(key);
if (data) {
const size = new Blob([data]).size;
stats.totalSize += size;
stats.totalItems++;
try {
let parsedData = data;
try {
JSON.parse(parsedData);
}
catch {
parsedData = await this.decrypt(data);
}
const item = JSON.parse(parsedData);
stats.items.push({
key: key.substring(prefix.length),
size,
timestamp: item.timestamp,
hasExpiry: Boolean(item.expiry),
});
}
catch {
stats.items.push({
key: key.substring(prefix.length),
size,
timestamp: 0,
hasExpiry: false,
});
}
}
}
}
const estimatedLimit = 5 * 1024 * 1024; // 5MB
stats.availableSpace = Math.max(0, estimatedLimit - stats.totalSize);
}
catch (error) {
this.log('Get storage stats failed', '', error);
}
return stats;
}
/**
* Updates existing data if key exists
*/
async updateData(key, updateFn, options = {}) {
try {
const currentValue = await this.getData(key, {
decrypt: options.encrypt,
});
const newValue = updateFn(currentValue);
return await this.setData(key, newValue, options);
}
catch (error) {
this.log('Update data failed', key, error);
return false;
}
}
/**
* Sets data only if key doesn't exist
*/
async setIfNotExists(key, value, options = {}) {
if (!(await this.hasKey(key))) {
return await this.setData(key, value, options);
}
return false;
}
/**
* Gets storage configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Gets the current storage type
*/
getStorageType() {
return this.config.storageType;
}
/**
* Switches storage type (creates a new instance)
*/
static withStorageType(config) {
return new NgStorageService(config);
}
/**
* Creates a localStorage instance
*/
static localStorage(config = {}) {
return new NgStorageService({ ...config, storageType: 'localStorage' });
}
/**
* Creates a sessionStorage instance
*/
static sessionStorage(config = {}) {
return new NgStorageService({ ...config, storageType: 'sessionStorage' });
}
/**
* Checks if storage is supported
*/
isStorageSupported() {
return this.isSupported;
}
/**
* Checks if encryption is supported
*/
isEncryptionSupported() {
return this.isCryptoSupported;
}
/**
* Forces cleanup of expired items
*/
async cleanup() {
const initialStats = await this.getStorageStats();
await this.cleanupExpiredItems();
const finalStats = await this.getStorageStats();
const removedCount = initialStats.totalItems - finalStats.totalItems;
this.log('Manual cleanup completed', '', { removedCount });
return removedCount;
}
/**
* Clears the encryption key (useful for key rotation)
*/
clearEncryptionKey() {
CryptoUtils.clearKey();
this.log('Encryption key cleared', '');
}
/**
* Destroys all subscriptions and cleanup
*/
destroy() {
this._changeSubject.complete();
this._keyChangeSubjects.forEach((subject) => subject.complete());
this._keyChangeSubjects.clear();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: NgStorageService, deps: [{ token: STORAGE_OPTIONS, optional: true }, { token: STORAGE_FLAGS, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: NgStorageService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: NgStorageService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [STORAGE_OPTIONS]
}] }, { type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [STORAGE_FLAGS]
}] }] });
/**
* Generated bundle index. Do not edit.
*/
export { DEFAULT_STORAGE_CONFIG, DEFAULT_STORAGE_FLAGS, NgStorageService, STORAGE_FLAGS, STORAGE_NAMED_OPTIONS, STORAGE_OPTIONS, provideNamedNgStorage, provideNgStorage, provideNgStorageConfig };
//# sourceMappingURL=ng7-storage.mjs.map