a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}