@jeanharo98/typed-storage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jean Haro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # typed-storage
2
+
3
+ Type-safe `localStorage` and `sessionStorage` with a signal-like API, TTL support, cross-tab sync, and automatic fallback to memory when storage is unavailable.
4
+
5
+ ```typescript
6
+ const appStorage = createStorage({
7
+ theme: 'dark' as 'dark' | 'light',
8
+ language: 'es' as 'es' | 'en',
9
+ fontSize: 16,
10
+ });
11
+
12
+ appStorage.theme.set('light'); // ✅ typed — only 'dark' | 'light' accepted
13
+ appStorage.theme.set('purple'); // ❌ TypeScript error at compile time
14
+ console.log(appStorage.theme()); // 'light' — persisted across reloads
15
+ ```
16
+
17
+ ## ✨ Features
18
+
19
+ - **Type-safe** — TypeScript infers types from your schema automatically
20
+ - **Signal-like API** — read with `signal()`, write with `signal.set(value)`
21
+ - **TTL / expiration** — keys expire automatically after a defined time
22
+ - **Cross-tab sync** — changes in one tab reflect in others via `StorageEvent`
23
+ - **Memory fallback** — works even when `localStorage` is unavailable (Safari private mode, quota exceeded)
24
+ - **Prefix namespacing** — avoid key collisions across apps or modules
25
+ - **sessionStorage support** — opt in per schema
26
+ - **onChange** — subscribe to value changes with a callback
27
+ - **Zero dependencies** — pure TypeScript, no external packages
28
+
29
+ ---
30
+
31
+ ## 📦 Installation
32
+
33
+ ```bash
34
+ npm install typed-storage
35
+ # or
36
+ pnpm add typed-storage
37
+ ```
38
+
39
+ ---
40
+
41
+ ## 🚀 Basic Usage
42
+
43
+ ```typescript
44
+ import { createStorage } from 'typed-storage';
45
+
46
+ const appStorage = createStorage({
47
+ theme: 'dark' as 'dark' | 'light',
48
+ language: 'es' as 'es' | 'en' | 'fr',
49
+ fontSize: 16,
50
+ sidebarOpen: true,
51
+ });
52
+
53
+ // Read
54
+ console.log(appStorage.theme()); // 'dark'
55
+
56
+ // Write — persists to localStorage automatically
57
+ appStorage.theme.set('light');
58
+ console.log(appStorage.theme()); // 'light'
59
+
60
+ // Reset to initial value
61
+ appStorage.theme.reset();
62
+ console.log(appStorage.theme()); // 'dark'
63
+
64
+ // Check if key exists in storage
65
+ appStorage.theme.has(); // true
66
+
67
+ // Remove key from storage
68
+ appStorage.theme.remove();
69
+ appStorage.theme.has(); // false
70
+
71
+ // Clear all keys in the schema
72
+ appStorage.clear();
73
+ ```
74
+
75
+ ---
76
+
77
+ ## ⚙️ Options
78
+
79
+ ```typescript
80
+ const appStorage = createStorage(schema, options);
81
+ ```
82
+
83
+ | Option | Type | Default | Description |
84
+ |--------|------|---------|-------------|
85
+ | `prefix` | `string` | — | Prepends `prefix:` to every key in localStorage |
86
+ | `storage` | `'local' \| 'session'` | `'local'` | Use `sessionStorage` instead of `localStorage` |
87
+ | `ttl` | `number` | — | Time to live in milliseconds — key expires after this time |
88
+ | `sync` | `boolean` | `false` | Sync values across browser tabs via `StorageEvent` |
89
+ | `encrypt` | `boolean` | `false` | Shows a security warning — see note below |
90
+
91
+ ### Prefix
92
+
93
+ ```typescript
94
+ const appStorage = createStorage(
95
+ { theme: 'dark' },
96
+ { prefix: 'myapp' }
97
+ );
98
+
99
+ appStorage.theme.set('light');
100
+ // Stored as: localStorage['myapp:theme'] = '"light"'
101
+ ```
102
+
103
+ ### TTL
104
+
105
+ ```typescript
106
+ const appStorage = createStorage(
107
+ { authToken: '' },
108
+ { ttl: 3600000 } // expires in 1 hour
109
+ );
110
+ ```
111
+
112
+ ### Cross-tab sync
113
+
114
+ ```typescript
115
+ const appStorage = createStorage(
116
+ { theme: 'dark' },
117
+ { sync: true }
118
+ );
119
+
120
+ // When another tab calls appStorage.theme.set('light'),
121
+ // this tab updates automatically
122
+ ```
123
+
124
+ ### sessionStorage
125
+
126
+ ```typescript
127
+ const sessionData = createStorage(
128
+ { step: 1 },
129
+ { storage: 'session' }
130
+ );
131
+ ```
132
+
133
+ ---
134
+
135
+ ## 🔔 onChange
136
+
137
+ Subscribe to changes on any key:
138
+
139
+ ```typescript
140
+ appStorage.theme.onChange((newValue) => {
141
+ console.log('theme changed to:', newValue);
142
+ document.body.setAttribute('data-theme', newValue);
143
+ });
144
+
145
+ appStorage.theme.set('light'); // → 'theme changed to: light'
146
+ appStorage.theme.reset(); // → 'theme changed to: dark'
147
+ appStorage.theme.remove(); // → 'theme changed to: dark' (initialValue)
148
+ ```
149
+
150
+ ---
151
+
152
+ ## 🔒 A note on encryption
153
+
154
+ If you pass `encrypt: true`, typed-storage will display a warning explaining why encrypting values in `localStorage` is not a secure practice — the encryption key must live in the frontend and is accessible to anyone who inspects your code.
155
+
156
+ For sensitive data such as auth tokens or personal information, use **httpOnly cookies** set by your server:
157
+
158
+ ```typescript
159
+ // In your backend (Express / NestJS):
160
+ res.cookie('authToken', token, {
161
+ httpOnly: true, // not accessible from JavaScript
162
+ secure: true, // HTTPS only
163
+ sameSite: 'strict'
164
+ });
165
+ ```
166
+
167
+ typed-storage is designed for:
168
+ - ✅ UI preferences (theme, language, font size)
169
+ - ✅ Navigation state (last visited, sidebar open)
170
+ - ✅ Non-sensitive user settings
171
+ - ❌ Auth tokens → use httpOnly cookies
172
+ - ❌ Passwords or financial data → never in localStorage
173
+
174
+ ---
175
+
176
+ ## 🅰️ Usage with Angular
177
+
178
+ typed-storage is framework-agnostic. For Angular, wrap it in a service with native Signals:
179
+
180
+ ```typescript
181
+ import { Service, signal } from '@angular/core';
182
+ import { createStorage } from 'typed-storage';
183
+
184
+ @Service()
185
+ export class StorageService {
186
+ private _storage = createStorage({
187
+ theme: 'dark' as 'dark' | 'light',
188
+ language: 'es' as 'es' | 'en',
189
+ fontSize: 16,
190
+ }, {
191
+ prefix: 'app',
192
+ sync: true,
193
+ });
194
+
195
+ // Native Angular Signals — reactive in any scenario including zoneless
196
+ theme = signal(this._storage.theme());
197
+ language = signal(this._storage.language());
198
+ fontSize = signal(this._storage.fontSize());
199
+
200
+ setTheme(value: 'dark' | 'light') {
201
+ this._storage.theme.set(value);
202
+ this.theme.set(value);
203
+ }
204
+
205
+ setLanguage(value: 'es' | 'en') {
206
+ this._storage.language.set(value);
207
+ this.language.set(value);
208
+ }
209
+ }
210
+ ```
211
+
212
+ ```html
213
+ <p>Theme: {{ storageService.theme() }}</p>
214
+ <button (click)="storageService.setTheme('light')">Light</button>
215
+ <button (click)="storageService.setTheme('dark')">Dark</button>
216
+ ```
217
+
218
+ ---
219
+
220
+ ## ⚛️ Usage with React
221
+
222
+ ```typescript
223
+ import { useState, useEffect } from 'react';
224
+ import { createStorage } from 'typed-storage';
225
+
226
+ const appStorage = createStorage({
227
+ theme: 'dark' as 'dark' | 'light',
228
+ });
229
+
230
+ export function useTheme() {
231
+ const [theme, setThemeState] = useState(appStorage.theme());
232
+
233
+ useEffect(() => {
234
+ appStorage.theme.onChange(setThemeState);
235
+ }, []);
236
+
237
+ const setTheme = (value: 'dark' | 'light') => {
238
+ appStorage.theme.set(value);
239
+ setThemeState(value);
240
+ };
241
+
242
+ return { theme, setTheme };
243
+ }
244
+ ```
245
+
246
+ ---
247
+
248
+ ## 📋 API Reference
249
+
250
+ ### `createStorage(schema, options?)`
251
+
252
+ Creates a storage object from a schema. Returns a `StorageResult<T>` with one `StorageSignal` per key, plus a `clear()` method.
253
+
254
+ ### `StorageSignal<T>`
255
+
256
+ | Member | Description |
257
+ |--------|-------------|
258
+ | `signal()` | Returns the current value |
259
+ | `signal.set(value)` | Updates the value and persists to storage |
260
+ | `signal.reset()` | Resets to `initialValue` and persists |
261
+ | `signal.remove()` | Removes the key from storage and resets in memory |
262
+ | `signal.has()` | Returns `true` if the key exists in storage |
263
+ | `signal.onChange(cb)` | Subscribes to value changes |
264
+
265
+ ### `StorageResult<T>`
266
+
267
+ | Member | Description |
268
+ |--------|-------------|
269
+ | `[key]` | One `StorageSignal` per schema key |
270
+ | `clear()` | Calls `reset()` on all keys in the schema |
271
+
272
+ ---
273
+
274
+ ## 📄 License
275
+
276
+ MIT
@@ -0,0 +1,2 @@
1
+ import { StorageSchema, StorageResult, StorageSignalOptions } from './types.js';
2
+ export declare function createStorage<T extends StorageSchema>(schema: T, options?: StorageSignalOptions): StorageResult<T>;
@@ -0,0 +1,32 @@
1
+ import { createStorageSignal } from './storage-signal.js';
2
+ export function createStorage(schema, options) {
3
+ if (options?.encrypt) {
4
+ console.warn(`
5
+ ⚠️ typed-storage: la opción encrypt está activada.
6
+
7
+ Encriptar valores en localStorage no es seguro —
8
+ la clave vive en el frontend y cualquier dev puede accederla.
9
+
10
+ Para datos sensibles usa:
11
+ ✅ httpOnly cookies (tokens, sesiones)
12
+ ✅ Variables de entorno en el servidor
13
+
14
+ typed-storage es ideal para:
15
+ ✅ Preferencias de UI (theme, language)
16
+ ✅ Estado de navegación
17
+ ❌ Tokens de autenticación
18
+ ❌ Datos financieros o personales sensibles
19
+ `);
20
+ }
21
+ const result = [];
22
+ let keys = Object.keys(schema);
23
+ for (let key of keys) {
24
+ result[key] = createStorageSignal(key, schema[key], options);
25
+ }
26
+ result.clear = () => {
27
+ for (let key of keys) {
28
+ result[key].reset();
29
+ }
30
+ };
31
+ return result;
32
+ }
@@ -0,0 +1,2 @@
1
+ export { createStorage } from './create-storage.js';
2
+ export type { StorageSignal, StorageSchema, StorageResult, StorageSignalOptions } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createStorage } from './create-storage.js';
@@ -0,0 +1,6 @@
1
+ export declare class MemoryStorage {
2
+ private data;
3
+ getItem(key: string): string | null;
4
+ setItem(key: string, value: string): void;
5
+ removeItem(key: string): void;
6
+ }
@@ -0,0 +1,14 @@
1
+ export class MemoryStorage {
2
+ constructor() {
3
+ this.data = new Map();
4
+ }
5
+ getItem(key) {
6
+ return this.data.get(key) ?? null;
7
+ }
8
+ setItem(key, value) {
9
+ this.data.set(key, value);
10
+ }
11
+ removeItem(key) {
12
+ this.data.delete(key);
13
+ }
14
+ }
@@ -0,0 +1,2 @@
1
+ import { StorageSignal, StorageSignalOptions } from "./types.js";
2
+ export declare function createStorageSignal<T>(key: string, initialValue: T, options?: StorageSignalOptions): StorageSignal<T>;
@@ -0,0 +1,94 @@
1
+ import { MemoryStorage } from "./memory-storage.js";
2
+ function safeParseJSON(value, fallback) {
3
+ if (!value)
4
+ return { value: fallback };
5
+ try {
6
+ const parsed = JSON.parse(value);
7
+ if (parsed && typeof parsed === 'object' && 'value' in parsed) {
8
+ return parsed;
9
+ }
10
+ return { value: JSON.parse(value) };
11
+ }
12
+ catch (error) {
13
+ console.warn(`Error al parsear JSON de localStorage. Usando valor por defecto.`, error);
14
+ return { value: fallback };
15
+ }
16
+ }
17
+ function getStorage(type) {
18
+ try {
19
+ const sto = type === 'session' ? sessionStorage : localStorage;
20
+ sto.setItem('__typed_storage_test__', '1');
21
+ sto.removeItem('__typed_storage_test__');
22
+ return sto;
23
+ }
24
+ catch {
25
+ console.warn('Storage no disponible, usando memoria como fallback');
26
+ return new MemoryStorage();
27
+ }
28
+ }
29
+ export function createStorageSignal(key, initialValue, options) {
30
+ let sto = getStorage(options?.storage ?? 'local');
31
+ if (options?.prefix) {
32
+ key = `${options.prefix}:${key}`;
33
+ }
34
+ const savedData = sto.getItem(key);
35
+ let currentValue;
36
+ const listeners = [];
37
+ function notify(value) {
38
+ listeners.forEach(cb => cb(value));
39
+ }
40
+ const item = safeParseJSON(savedData, initialValue);
41
+ if (item.expiresAt === undefined) {
42
+ currentValue = !savedData ? initialValue : item.value;
43
+ }
44
+ else {
45
+ if (Date.now() <= item.expiresAt) {
46
+ currentValue = item.value;
47
+ }
48
+ else {
49
+ sto.removeItem(key);
50
+ currentValue = initialValue;
51
+ }
52
+ }
53
+ const signalBase = function () {
54
+ return currentValue;
55
+ };
56
+ if (options?.sync) {
57
+ window.addEventListener('storage', (event) => {
58
+ if (event.key === key) {
59
+ if (event.newValue === null) {
60
+ notify(initialValue);
61
+ return currentValue = initialValue;
62
+ }
63
+ const item = safeParseJSON(event.newValue, initialValue);
64
+ notify(item.value);
65
+ return currentValue = item.value;
66
+ }
67
+ });
68
+ }
69
+ signalBase.set = function (newValue) {
70
+ currentValue = newValue;
71
+ notify(currentValue);
72
+ sto.setItem(key, JSON.stringify({
73
+ value: newValue,
74
+ expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
75
+ }));
76
+ };
77
+ signalBase.reset = function () {
78
+ currentValue = initialValue;
79
+ notify(currentValue);
80
+ sto.setItem(key, JSON.stringify(initialValue));
81
+ };
82
+ signalBase.has = function () {
83
+ return !!sto.getItem(key);
84
+ };
85
+ signalBase.remove = function () {
86
+ sto.removeItem(key);
87
+ currentValue = initialValue;
88
+ notify(currentValue);
89
+ };
90
+ signalBase.onChange = function (callback) {
91
+ listeners.push(callback);
92
+ };
93
+ return signalBase;
94
+ }
@@ -0,0 +1,21 @@
1
+ export interface StorageSignalOptions {
2
+ prefix?: string;
3
+ storage?: 'local' | 'session';
4
+ ttl?: number;
5
+ sync?: boolean;
6
+ encrypt?: boolean;
7
+ }
8
+ export interface StorageSignal<T> {
9
+ (): T;
10
+ set(value: T): void;
11
+ reset(): void;
12
+ remove(): void;
13
+ has(): boolean;
14
+ onChange(callback: (value: T) => void): void;
15
+ }
16
+ export type StorageSchema = Record<string, any>;
17
+ export type StorageResult<T extends StorageSchema> = {
18
+ [K in keyof T]: StorageSignal<T[K]>;
19
+ } & {
20
+ clear(): void;
21
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/index.html ADDED
@@ -0,0 +1,61 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>typed-storage test</title>
5
+ </head>
6
+ <body>
7
+ <script type="module">
8
+ import { createStorage } from './dist/index.js';
9
+
10
+ const appStorage = createStorage({
11
+ theme: 'dark',
12
+ fontSize: 16,
13
+ sidebarOpen: true,
14
+ }, {
15
+ prefix: 'myapp',
16
+ ttl: 5000, // expira en 5 segundos — para probar
17
+ sync: true
18
+ });
19
+
20
+ // Prueba 1 — valores iniciales
21
+ console.log('theme:', appStorage.theme());
22
+ console.log('fontSize:', appStorage.fontSize());
23
+
24
+ // Prueba 2 — set
25
+ appStorage.theme.set('light');
26
+ console.log('theme después de set:', appStorage.theme());
27
+
28
+ // Prueba 3 — reset
29
+ appStorage.theme.reset();
30
+ console.log('theme después de reset:', appStorage.theme());
31
+
32
+ // Prueba 4 — clear
33
+ appStorage.clear();
34
+ console.log('theme después de clear:', appStorage.theme());
35
+
36
+ // Prueba 5 — warning encrypt
37
+ const sensitiveStorage = createStorage({
38
+ token: '',
39
+ }, { encrypt: true });
40
+
41
+ // Prueba has()
42
+ console.log('¿tiene theme?', appStorage.theme.has());
43
+
44
+ // Prueba onChange()
45
+ appStorage.theme.onChange((newValue) => {
46
+ console.log('theme cambió a:', newValue);
47
+ });
48
+
49
+ // Prueba set — debería disparar onChange
50
+ appStorage.theme.set('light');
51
+ appStorage.theme.set('dark');
52
+
53
+ // Prueba reset — debería disparar onChange
54
+ appStorage.theme.reset();
55
+
56
+ // Prueba remove — debería disparar onChange
57
+ appStorage.theme.remove();
58
+ console.log('¿tiene theme después de remove?', appStorage.theme.has());
59
+ </script>
60
+ </body>
61
+ </html>
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@jeanharo98/typed-storage",
3
+ "version": "0.1.0",
4
+ "description": "Type-safe localStorage with reactive signals",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "keywords": [],
8
+ "author": "",
9
+ "license": "ISC",
10
+ "devEngines": {
11
+ "packageManager": {
12
+ "name": "pnpm",
13
+ "version": "11.1.1",
14
+ "onFail": "download"
15
+ }
16
+ },
17
+ "devDependencies": {
18
+ "@vitest/coverage-v8": "^4.1.8",
19
+ "jsdom": "^29.1.1",
20
+ "ts-node": "^10.9.2",
21
+ "typescript": "^6.0.3",
22
+ "vitest": "^4.1.8"
23
+ },
24
+ "scripts": {
25
+ "dev": "ts-node src/index.ts",
26
+ "build": "tsc",
27
+ "test": "vitest",
28
+ "test:coverage": "vitest --coverage"
29
+ }
30
+ }
@@ -0,0 +1,41 @@
1
+ import { StorageSchema, StorageResult, StorageSignalOptions } from './types.js';
2
+ import { createStorageSignal } from './storage-signal.js';
3
+
4
+ export function createStorage<T extends StorageSchema>(
5
+ schema: T,
6
+ options?: StorageSignalOptions
7
+ ): StorageResult<T> {
8
+ if ( options?.encrypt ) {
9
+ console.warn(`
10
+ ⚠️ typed-storage: la opción encrypt está activada.
11
+
12
+ Encriptar valores en localStorage no es seguro —
13
+ la clave vive en el frontend y cualquier dev puede accederla.
14
+
15
+ Para datos sensibles usa:
16
+ ✅ httpOnly cookies (tokens, sesiones)
17
+ ✅ Variables de entorno en el servidor
18
+
19
+ typed-storage es ideal para:
20
+ ✅ Preferencias de UI (theme, language)
21
+ ✅ Estado de navegación
22
+ ❌ Tokens de autenticación
23
+ ❌ Datos financieros o personales sensibles
24
+ `);
25
+ }
26
+
27
+ const result: any = [];
28
+
29
+ let keys = Object.keys(schema);
30
+ for ( let key of keys ) {
31
+ result[key] = createStorageSignal(key, schema[key], options)
32
+ }
33
+
34
+ result.clear = () => {
35
+ for ( let key of keys ) {
36
+ result[key].reset(); // Limpiamos todas las keys del schema
37
+ }
38
+ }
39
+
40
+ return result as StorageResult<T>;
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createStorage } from './create-storage.js';
2
+ export type { StorageSignal, StorageSchema, StorageResult, StorageSignalOptions } from './types.js';
@@ -0,0 +1,16 @@
1
+ // Si localStorage falla → usamos esto transparentemente
2
+ export class MemoryStorage {
3
+ private data = new Map<string, string>();
4
+
5
+ getItem(key: string): string | null {
6
+ return this.data.get(key) ?? null;
7
+ }
8
+
9
+ setItem(key: string, value: string): void {
10
+ this.data.set(key, value);
11
+ }
12
+
13
+ removeItem(key: string): void {
14
+ this.data.delete(key);
15
+ }
16
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { createStorageSignal } from './storage-signal.js';
3
+
4
+ describe('createStorageSignal', () => {
5
+ // Limpia localStorage antes de cada test
6
+ beforeEach(() => {
7
+ localStorage.clear();
8
+ });
9
+
10
+ it('debe retornar el initialValue si no hay nada en localStorage', () => {
11
+ // 1. ARRANGE — preparas los datos
12
+ const signal = createStorageSignal('theme', 'dark');
13
+
14
+ // 2. ACT — ejecutas la acción
15
+ const value = signal();
16
+
17
+ // 3. ASSERT — verificas el resultado
18
+ expect(value).toBe('dark');
19
+ });
20
+
21
+ it('debe guardar el valor en localStorage al llamar set()', () => {
22
+ const theme = createStorageSignal('theme', 'dark');
23
+
24
+ theme.set('light');
25
+ expect(localStorage.getItem('theme')).not.toBeNull(); // que exista
26
+ });
27
+
28
+ it('debe volver al initialValue al llamar reset()', () => {
29
+ const theme = createStorageSignal('theme', 'dark');
30
+ theme.set('light');
31
+ theme.reset();
32
+
33
+ expect(theme()).toBe('dark'); // Debe ser dark
34
+ });
35
+
36
+ it('debe aplicar el prefix a la key en localStorage', () => {
37
+ const theme = createStorageSignal('theme', 'dark', { prefix: 'app' });
38
+ theme.set('light');
39
+
40
+ expect(localStorage.getItem('app:theme')).not.toBeNull(); // que exista
41
+ expect(localStorage.getItem('theme')).toBeNull(); // que no exista
42
+ });
43
+ });
44
+
45
+ describe('has y remove', () => {
46
+ beforeEach(() => localStorage.clear());
47
+
48
+ it('has() debe retornar true si la key existe', () => {
49
+ const theme = createStorageSignal('theme', 'dark');
50
+ theme.set('light');
51
+ expect(theme.has()).toBeTruthy();
52
+ });
53
+
54
+ it('has() debe retornar false si la key no existe', () => {
55
+ const theme = createStorageSignal('theme', 'dark');
56
+
57
+ expect(theme.has()).toBeFalsy();
58
+ });
59
+
60
+ it('remove() debe eliminar la key del localStorage', () => {
61
+ const theme = createStorageSignal('theme', 'dark');
62
+ theme.set('light');
63
+ theme.remove();
64
+ expect(localStorage.getItem('theme')).toBeNull();
65
+ expect(theme()).toBe('dark');
66
+ });
67
+ });
68
+
69
+ describe('onChange', () => {
70
+ beforeEach(() => localStorage.clear());
71
+
72
+ it('debe llamar el callback cuando se llama set()', () => {
73
+ const theme = createStorageSignal('theme', 'dark');
74
+ const callback = vi.fn();
75
+ theme.onChange(callback);
76
+ theme.set('light');
77
+ expect(callback).toHaveBeenCalledWith('light');
78
+ });
79
+
80
+ it('debe llamar el callback cuando se llama reset()', () => {
81
+ const theme = createStorageSignal('theme', 'dark');
82
+ const callback = vi.fn();
83
+ theme.onChange(callback);
84
+ theme.reset();
85
+ expect(callback).toHaveBeenCalledWith('dark');
86
+ });
87
+ });
@@ -0,0 +1,145 @@
1
+ // Tipos
2
+ import { StorageSignal, StorageSignalOptions } from "./types.js";
3
+
4
+ // Memory
5
+ import { MemoryStorage } from "./memory-storage.js";
6
+
7
+ // Interface
8
+ interface StoredValue<T> {
9
+ value: T;
10
+ expiresAt?: number; // undefined = sin expiración
11
+ }
12
+
13
+ // Asegurar el parseo del JSON, verificamos si el valor que se obtiene es del tipo T
14
+ // Si no es del tipo T entonces retornamos el valor inicial
15
+ function safeParseJSON<T>(value: string, fallback: T): StoredValue<T> {
16
+ if (!value) return { value: fallback };
17
+
18
+ try {
19
+ const parsed = JSON.parse(value);
20
+
21
+ // Si tiene la forma StoredValue -> ya viene con TTL
22
+ if ( parsed && typeof parsed === 'object' && 'value' in parsed ) {
23
+ return parsed as StoredValue<T>;
24
+ }
25
+
26
+ // Si es un valor simple (datos sin TTL) -> envolvemos
27
+ return { value: JSON.parse(value) as T };
28
+ } catch (error) {
29
+ console.warn(`Error al parsear JSON de localStorage. Usando valor por defecto.`, error);
30
+
31
+ // Si hay error por el parse digamos entonces regresa el valor inicial sin TTL
32
+ return { value: fallback };
33
+ }
34
+ }
35
+
36
+ // Obtenemos el valor del localStorage o SessionStorage, si sale error entonces se usará el MemoryStorage
37
+ function getStorage(type: 'local' | 'session'): Storage | MemoryStorage {
38
+ try {
39
+ const sto = type === 'session' ? sessionStorage : localStorage;
40
+
41
+ // Prueba que realmente funciona escribiendo y borrando
42
+ sto.setItem('__typed_storage_test__', '1');
43
+ sto.removeItem('__typed_storage_test__');
44
+
45
+ return sto;
46
+ } catch {
47
+ console.warn('Storage no disponible, usando memoria como fallback');
48
+ return new MemoryStorage();
49
+ }
50
+ }
51
+
52
+ // Creamos el Storage en modo signal
53
+ export function createStorageSignal<T>(
54
+ key: string,
55
+ initialValue: T,
56
+ options?: StorageSignalOptions
57
+ ): StorageSignal<T> {
58
+ // Obtenemos que tipo de storage será
59
+ let sto = getStorage(options?.storage ?? 'local');
60
+
61
+ // Verificamos si tiene opciones
62
+ if ( options?.prefix ) {
63
+ key = `${options.prefix}:${key}`;
64
+ }
65
+
66
+ const savedData = sto.getItem(key);
67
+ let currentValue: T;
68
+
69
+ const listeners: Array<(value: T) => void> = [];
70
+ function notify ( value: T ): void {
71
+ listeners.forEach(cb => cb(value));
72
+ }
73
+
74
+ // Asegurarnos que el item obtenido sea de tipo StoredValue
75
+ const item = safeParseJSON(savedData!, initialValue);
76
+
77
+ if ( item.expiresAt === undefined ) {
78
+ currentValue = !savedData ? initialValue : item.value;
79
+ } else {
80
+ if ( Date.now() <= item.expiresAt! ) {
81
+ currentValue = item.value;
82
+ } else {
83
+ sto.removeItem(key);
84
+ currentValue = initialValue;
85
+ }
86
+ }
87
+
88
+ const signalBase = function(): T {
89
+ return currentValue;
90
+ }
91
+
92
+ // Verificamos si tiene sincronizacion activo
93
+ if ( options?.sync ) {
94
+ window.addEventListener('storage', (event: StorageEvent) => {
95
+ // Si el key es igual a nuestro key
96
+ if ( event.key === key ) {
97
+ // Si el nuevo valor es null entonces se le asigna el initialValue
98
+ if ( event.newValue === null ) {
99
+ notify(initialValue);
100
+ return currentValue = initialValue;
101
+ }
102
+
103
+ // Parseamos el nuevo valor
104
+ const item = safeParseJSON(event.newValue, initialValue);
105
+
106
+ notify(item.value as T);
107
+ return currentValue = item.value as T;
108
+ }
109
+ })
110
+ }
111
+
112
+ signalBase.set = function ( newValue: T ): void {
113
+ currentValue = newValue;
114
+ notify(currentValue);
115
+ sto.setItem(
116
+ key,
117
+ JSON.stringify({
118
+ value: newValue,
119
+ expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
120
+ })
121
+ );
122
+ }
123
+
124
+ signalBase.reset = function(): void {
125
+ currentValue = initialValue;
126
+ notify(currentValue);
127
+ sto.setItem(key, JSON.stringify(initialValue));
128
+ }
129
+
130
+ signalBase.has = function(): boolean {
131
+ return !!sto.getItem(key);
132
+ }
133
+
134
+ signalBase.remove = function(): void {
135
+ sto.removeItem(key);
136
+ currentValue = initialValue;
137
+ notify(currentValue);
138
+ }
139
+
140
+ signalBase.onChange = function(callback: (value: T) => void): void {
141
+ listeners.push(callback);
142
+ }
143
+
144
+ return signalBase as StorageSignal<T>;
145
+ }
package/src/types.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Opciones de Storage
2
+ export interface StorageSignalOptions {
3
+ prefix?: string;
4
+ storage?: 'local' | 'session';
5
+ ttl?: number;
6
+ sync?: boolean;
7
+ encrypt?: boolean;
8
+ }
9
+
10
+ // Objeto reactivo con getter, setter y reset
11
+ export interface StorageSignal<T> {
12
+ (): T, // Leer el valor (como signal)
13
+ set(value: T): void; // escribir y persistir
14
+ reset(): void; // volver al valor inicial
15
+ remove(): void; // borra la key del storage
16
+ has(): boolean; // verifica si existe en storage
17
+ onChange(callback: ( value: T ) => void): void;
18
+ }
19
+
20
+ // El schema que el usuario define
21
+ export type StorageSchema = Record<string, any>;
22
+
23
+ // El resultado de createStorage - mapea cada key a su StorageSignal
24
+ export type StorageResult<T extends StorageSchema> = {
25
+ [K in keyof T]: StorageSignal<T[K]>
26
+ } & {
27
+ clear(): void;
28
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ // Visit https://aka.ms/tsconfig to read more about this file
3
+ "compilerOptions": {
4
+ // File Layout
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+
8
+ // Environment Settings
9
+ // See also https://aka.ms/tsconfig/module
10
+ "module": "esnext",
11
+ "target": "ES2020",
12
+ "moduleResolution": "bundler",
13
+
14
+ // Other Outputs
15
+ "sourceMap": false, // Para saber en que línea está el problema o dato que arroja en consola en mi TypeScript
16
+ "declaration": true,
17
+ "declarationDir": "./dist",
18
+ "removeComments": true, // Esto es para remover o quitar los comentarios que coloque en el TS y que no aparezcan en el JS
19
+ "esModuleInterop": true,
20
+ "experimentalDecorators": true,
21
+
22
+ // Style Options
23
+ // # Recomendación siempre dejemosle en true
24
+ "strictNullChecks": true, // Para que un valor siempre tenga un valor
25
+
26
+ // Recommended Options
27
+ "strict": true,
28
+ },
29
+ "include": [
30
+ "./src/"
31
+ ],
32
+ "exclude": [
33
+ "src/**/*.test.ts"
34
+ ]
35
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'jsdom',
6
+ globals: true,
7
+ }
8
+ });