a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 8.3 kB view raw
1/* eslint-disable unicorn/prefer-add-event-listener */ 2/** 3 * Persistence plugin for synchronizing signals with storage 4 * Supports localStorage, sessionStorage, IndexedDB, and custom adapters 5 */ 6 7import { isNil, kebabToCamel } from "$core/shared"; 8import type { Optional } from "$types/helpers"; 9import type { PluginContext, Scope, Signal, StorageAdapter } from "$types/volt"; 10 11const storageAdapterRegistry = new Map<string, StorageAdapter>(); 12 13/** 14 * Register a custom storage adapter. 15 * 16 * @param name - Adapter name (used in data-volt-persist="signal:name") 17 * @param adapter - Storage adapter implementation 18 */ 19export function registerStorageAdapter(name: string, adapter: StorageAdapter): void { 20 storageAdapterRegistry.set(name, adapter); 21} 22 23const localStorageAdapter = { 24 get(key: string) { 25 const value = localStorage.getItem(key); 26 if (isNil(value)) return void 0; 27 try { 28 return JSON.parse(value); 29 } catch { 30 return value; 31 } 32 }, 33 set(key: string, value: unknown) { 34 localStorage.setItem(key, JSON.stringify(value)); 35 }, 36 remove(key: string) { 37 localStorage.removeItem(key); 38 }, 39} satisfies StorageAdapter; 40 41const sessionStorageAdapter = { 42 get(key: string) { 43 const value = sessionStorage.getItem(key); 44 if (isNil(value)) return void 0; 45 try { 46 return JSON.parse(value); 47 } catch { 48 return value; 49 } 50 }, 51 set(key: string, value: unknown) { 52 sessionStorage.setItem(key, JSON.stringify(value)); 53 }, 54 remove(key: string) { 55 sessionStorage.removeItem(key); 56 }, 57} satisfies StorageAdapter; 58 59const idbAdapter = { 60 async get(key: string) { 61 const db = await openDB(); 62 return new Promise((resolve, reject) => { 63 const transaction = db.transaction(["voltStore"], "readonly"); 64 const store = transaction.objectStore("voltStore"); 65 const request = store.get(key); 66 67 request.onsuccess = () => { 68 resolve(request.result?.value); 69 }; 70 request.onerror = () => { 71 reject(request.error); 72 }; 73 }); 74 }, 75 async set(key: string, value: unknown) { 76 const db = await openDB(); 77 return new Promise<void>((resolve, reject) => { 78 const transaction = db.transaction(["voltStore"], "readwrite"); 79 const store = transaction.objectStore("voltStore"); 80 const request = store.put({ key, value }); 81 82 request.onsuccess = () => { 83 resolve(); 84 }; 85 request.onerror = () => { 86 reject(request.error); 87 }; 88 }); 89 }, 90 async remove(key: string) { 91 const db = await openDB(); 92 return new Promise<void>((resolve, reject) => { 93 const transaction = db.transaction(["voltStore"], "readwrite"); 94 const store = transaction.objectStore("voltStore"); 95 const request = store.delete(key); 96 97 request.onsuccess = () => { 98 resolve(); 99 }; 100 request.onerror = () => { 101 reject(request.error); 102 }; 103 }); 104 }, 105} satisfies StorageAdapter; 106 107let dbPromise: Optional<Promise<IDBDatabase>>; 108 109/** 110 * Open or create the IndexedDB database ({@link IDBDatabase}) 111 */ 112function openDB(): Promise<IDBDatabase> { 113 if (dbPromise) return dbPromise; 114 115 dbPromise = new Promise((resolve, reject) => { 116 const request = indexedDB.open("voltDB", 1); 117 118 request.onupgradeneeded = () => { 119 const db = request.result; 120 if (!db.objectStoreNames.contains("voltStore")) { 121 db.createObjectStore("voltStore", { keyPath: "key" }); 122 } 123 }; 124 125 request.onsuccess = () => { 126 resolve(request.result); 127 }; 128 129 request.onerror = () => { 130 reject(request.error); 131 }; 132 }); 133 134 return dbPromise; 135} 136 137function getStorageAdapter(type: string): Optional<StorageAdapter> { 138 switch (type) { 139 case "local": { 140 return localStorageAdapter; 141 } 142 case "session": { 143 return sessionStorageAdapter; 144 } 145 case "indexeddb": { 146 return idbAdapter; 147 } 148 default: { 149 return storageAdapterRegistry.get(type); 150 } 151 } 152} 153 154function resolveCanonicalPath(scope: Scope, rawPath: string): string { 155 const trimmed = rawPath.trim(); 156 if (!trimmed) { 157 return trimmed; 158 } 159 160 const parts = trimmed.split("."); 161 const resolved: string[] = []; 162 let current: unknown = scope; 163 164 for (const part of parts) { 165 if (isNil(current) || typeof current !== "object") { 166 resolved.push(part); 167 current = undefined; 168 continue; 169 } 170 171 const record = current as Record<string, unknown>; 172 173 if (Object.hasOwn(record, part)) { 174 resolved.push(part); 175 current = record[part]; 176 continue; 177 } 178 179 const camelCandidate = kebabToCamel(part); 180 if (Object.hasOwn(record, camelCandidate)) { 181 resolved.push(camelCandidate); 182 current = record[camelCandidate]; 183 continue; 184 } 185 186 const lower = part.toLowerCase(); 187 const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === lower); 188 189 if (matchedKey) { 190 resolved.push(matchedKey); 191 current = record[matchedKey]; 192 continue; 193 } 194 195 resolved.push(part); 196 current = undefined; 197 } 198 199 return resolved.join("."); 200} 201 202function resolveSignal(ctx: PluginContext, rawPath: string): Optional<{ path: string; signal: Signal<unknown> }> { 203 const trimmed = rawPath.trim(); 204 if (!trimmed) { 205 return undefined; 206 } 207 208 const canonicalPath = resolveCanonicalPath(ctx.scope, trimmed); 209 const candidatePaths = new Set([canonicalPath, trimmed]); 210 211 for (const candidate of candidatePaths) { 212 const found = ctx.findSignal(candidate); 213 if (found) { 214 return { path: candidate, signal: found as Signal<unknown> }; 215 } 216 } 217} 218 219function normalizeStorageType(type: string): { key: string; original: string } { 220 const original = type.trim(); 221 const normalized = original.toLowerCase().replaceAll(/[\s_-]/g, ""); 222 223 switch (normalized) { 224 case "local": 225 case "localstorage": { 226 return { key: "local", original }; 227 } 228 case "session": 229 case "sessionstorage": { 230 return { key: "session", original }; 231 } 232 case "indexeddb": 233 case "indexed-db": { 234 return { key: "indexeddb", original }; 235 } 236 default: { 237 return { key: original, original }; 238 } 239 } 240} 241 242/** 243 * Persist plugin handler. 244 * Synchronizes signal values with persistent storage. 245 * 246 * Syntax: data-volt-persist="signalPath:storageType" 247 * Examples: 248 * - data-volt-persist="count:local" 249 * - data-volt-persist="formData:session" 250 * - data-volt-persist="userData:indexeddb" 251 * - data-volt-persist="settings:customAdapter" 252 */ 253export function persistPlugin(ctx: PluginContext, value: string): void { 254 const parts = value.split(":"); 255 if (parts.length !== 2) { 256 console.error(`Invalid persist binding: "${value}". Expected format: "signalPath:storageType"`); 257 return; 258 } 259 260 const [signalPath, storageType] = parts; 261 const resolvedSignal = resolveSignal(ctx, signalPath); 262 263 if (!resolvedSignal) { 264 console.error(`Signal "${signalPath.trim()}" not found in scope for persist binding`); 265 return; 266 } 267 268 const { key: adapterKey, original } = normalizeStorageType(storageType); 269 const adapter = getStorageAdapter(adapterKey) ?? (adapterKey === original ? undefined : getStorageAdapter(original)); 270 if (!adapter) { 271 console.error(`Unknown storage type: "${storageType.trim()}"`); 272 return; 273 } 274 275 const storageKey = `volt:${resolvedSignal.path}`; 276 277 try { 278 const result = adapter.get(storageKey); 279 if (result instanceof Promise) { 280 result.then((storedValue) => { 281 if (storedValue !== undefined) { 282 resolvedSignal.signal.set(storedValue); 283 } 284 }).catch((error) => { 285 console.error(`Failed to load persisted value for "${signalPath.trim()}":`, error); 286 }); 287 } else if (result !== undefined) { 288 resolvedSignal.signal.set(result); 289 } 290 } catch (error) { 291 console.error(`Failed to load persisted value for "${signalPath.trim()}":`, error); 292 } 293 294 const unsubscribe = resolvedSignal.signal.subscribe((newValue) => { 295 try { 296 const result = adapter.set(storageKey, newValue); 297 if (result instanceof Promise) { 298 result.catch((error) => { 299 console.error(`Failed to persist value for "${signalPath.trim()}":`, error); 300 }); 301 } 302 } catch (error) { 303 console.error(`Failed to persist value for "${signalPath.trim()}":`, error); 304 } 305 }); 306 307 ctx.addCleanup(unsubscribe); 308}