@aituber-onair/kizuna
Version:
A sophisticated bond system (絆 - Kizuna) for managing relationships between users and AI characters in AITuber OnAir.
314 lines • 10.8 kB
JavaScript
/**
* LocalStorageProvider - Storage provider using LocalStorage
*
* Persists data using browser's LocalStorage
*/
import { StorageProvider, StorageError, StorageErrorCode, } from "./StorageProvider";
/**
* Default configuration
*/
const DEFAULT_CONFIG = {
enableCompression: false,
enableEncryption: false,
maxStorageSize: 5 * 1024 * 1024, // 5MB
};
/**
* Storage provider using LocalStorage
*/
export class LocalStorageProvider extends StorageProvider {
constructor(config = {}) {
super();
this.config = { ...DEFAULT_CONFIG, ...config };
if (!this.isAvailable()) {
throw new StorageError("LocalStorage is not available", StorageErrorCode.NOT_AVAILABLE);
}
}
/**
* Save data
*/
async save(key, data) {
this.validateKey(key);
try {
const serializedData = this.serialize(data);
const processedData = await this.processDataForStorage(serializedData);
// Size check
if (!(await this.canStore(processedData))) {
throw new StorageError("Storage quota exceeded", StorageErrorCode.QUOTA_EXCEEDED);
}
localStorage.setItem(key, processedData);
}
catch (error) {
if (error instanceof StorageError) {
throw error;
}
// Handle quota exceeded error
if (error instanceof Error && error.name === "QuotaExceededError") {
throw new StorageError("Storage quota exceeded", StorageErrorCode.QUOTA_EXCEEDED, error);
}
throw new StorageError(`Failed to save data: ${error instanceof Error ? error.message : String(error)}`, StorageErrorCode.UNKNOWN_ERROR, error instanceof Error ? error : undefined);
}
}
/**
* Load data
*/
async load(key) {
this.validateKey(key);
try {
const rawData = localStorage.getItem(key);
if (rawData === null) {
return null;
}
const processedData = await this.processDataFromStorage(rawData);
return this.deserialize(processedData);
}
catch (error) {
throw new StorageError(`Failed to load data: ${error instanceof Error ? error.message : String(error)}`, StorageErrorCode.UNKNOWN_ERROR, error instanceof Error ? error : undefined);
}
}
/**
* Remove data
*/
async remove(key) {
this.validateKey(key);
try {
localStorage.removeItem(key);
}
catch (error) {
throw new StorageError(`Failed to remove data: ${error.message}`, StorageErrorCode.UNKNOWN_ERROR, error);
}
}
/**
* Get all keys starting with specified prefix
*/
async getAllKeys(keyPrefix) {
try {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
if (keyPrefix) {
if (key.startsWith(keyPrefix)) {
keys.push(key);
}
}
else {
keys.push(key);
}
}
}
return keys;
}
catch (error) {
throw new StorageError(`Failed to get all keys: ${error.message}`, StorageErrorCode.UNKNOWN_ERROR, error);
}
}
/**
* Clear storage with specified prefix
*/
async clear(keyPrefix) {
try {
const keys = await this.getAllKeys(keyPrefix);
for (const key of keys) {
await this.remove(key);
}
}
catch (error) {
throw new StorageError(`Failed to clear storage: ${error.message}`, StorageErrorCode.UNKNOWN_ERROR, error);
}
}
/**
* Check if LocalStorage is available
*/
isAvailable() {
try {
if (typeof localStorage === "undefined") {
return false;
}
// Write test
const testKey = "__kizuna_test__";
localStorage.setItem(testKey, "test");
localStorage.removeItem(testKey);
return true;
}
catch {
return false;
}
}
/**
* Check if data can be stored
*/
async canStore(data) {
try {
const serializedData = typeof data === "string" ? data : this.serialize(data);
const processedData = await this.processDataForStorage(serializedData);
const dataSize = this.getDataSize(processedData);
// Maximum size check
if (this.config.maxStorageSize > 0 &&
dataSize > this.config.maxStorageSize) {
return false;
}
// Actual storage test
const testKey = "__kizuna_size_test__";
try {
localStorage.setItem(testKey, processedData);
localStorage.removeItem(testKey);
return true;
}
catch {
return false;
}
}
catch {
return false;
}
}
/**
* Get storage information
*/
async getStorageInfo() {
try {
const keys = await this.getAllKeys();
let totalUsed = 0;
// Calculate usage
for (const key of keys) {
const data = localStorage.getItem(key);
if (data) {
totalUsed += this.getDataSize(data);
}
}
// Estimate LocalStorage total capacity (typically 5-10MB)
let estimatedTotal;
try {
// Capacity test with large data
const testData = "x".repeat(1024 * 1024); // 1MB
let testSize = 0;
const testKey = "__capacity_test__";
for (let i = 0; i < 20; i++) {
// Test up to 20MB
try {
localStorage.setItem(testKey, testData.repeat(i + 1));
testSize = (i + 1) * 1024 * 1024;
}
catch {
break;
}
}
localStorage.removeItem(testKey);
estimatedTotal = testSize + totalUsed;
}
catch {
// undefined if test fails
}
return {
used: totalUsed,
available: estimatedTotal ? estimatedTotal - totalUsed : undefined,
total: estimatedTotal,
keyCount: keys.length,
lastUpdated: new Date(),
};
}
catch (error) {
throw new StorageError(`Failed to get storage info: ${error.message}`, StorageErrorCode.UNKNOWN_ERROR, error);
}
}
// ============================================================================
// Private methods
// ============================================================================
/**
* Process data for storage (compression/encryption)
*/
async processDataForStorage(data) {
let processedData = data;
// Compression
if (this.config.enableCompression) {
processedData = await this.compressData(processedData);
}
// Encryption
if (this.config.enableEncryption && this.config.encryptionKey) {
processedData = await this.encryptData(processedData, this.config.encryptionKey);
}
return processedData;
}
/**
* Process data from storage (decryption/decompression)
*/
async processDataFromStorage(data) {
let processedData = data;
// Decryption
if (this.config.enableEncryption && this.config.encryptionKey) {
processedData = await this.decryptData(processedData, this.config.encryptionKey);
}
// Decompression
if (this.config.enableCompression) {
processedData = await this.decompressData(processedData);
}
return processedData;
}
/**
* Compress data (simple implementation)
*/
async compressData(data) {
// In actual implementation, use libraries like LZ-string or pako
// Here we use simple Base64 encoding only
try {
return btoa(unescape(encodeURIComponent(data)));
}
catch (error) {
throw new StorageError("Compression failed", StorageErrorCode.SERIALIZATION_ERROR, error);
}
}
/**
* Decompress data
*/
async decompressData(data) {
try {
return decodeURIComponent(escape(atob(data)));
}
catch (error) {
throw new StorageError("Decompression failed", StorageErrorCode.SERIALIZATION_ERROR, error);
}
}
/**
* Encrypt data (simple implementation)
*/
async encryptData(data, key) {
// In actual implementation, use Web Crypto API or Crypto-JS
// Here we implement simple XOR encryption
try {
const keyBytes = new TextEncoder().encode(key);
const dataBytes = new TextEncoder().encode(data);
const encryptedBytes = new Uint8Array(dataBytes.length);
for (let i = 0; i < dataBytes.length; i++) {
const dataByte = dataBytes[i] ?? 0;
const keyByte = keyBytes[i % keyBytes.length] ?? 0;
encryptedBytes[i] = dataByte ^ keyByte;
}
return btoa(String.fromCharCode(...encryptedBytes));
}
catch (error) {
throw new StorageError("Encryption failed", StorageErrorCode.SERIALIZATION_ERROR, error);
}
}
/**
* Decrypt data
*/
async decryptData(data, key) {
try {
const keyBytes = new TextEncoder().encode(key);
const encryptedBytes = new Uint8Array(atob(data)
.split("")
.map((char) => char.charCodeAt(0)));
const decryptedBytes = new Uint8Array(encryptedBytes.length);
for (let i = 0; i < encryptedBytes.length; i++) {
const encryptedByte = encryptedBytes[i] ?? 0;
const keyByte = keyBytes[i % keyBytes.length] ?? 0;
decryptedBytes[i] = encryptedByte ^ keyByte;
}
return new TextDecoder().decode(decryptedBytes);
}
catch (error) {
throw new StorageError("Decryption failed", StorageErrorCode.SERIALIZATION_ERROR, error);
}
}
}
//# sourceMappingURL=LocalStorageProvider.js.map