@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 +21 -0
- package/README.md +276 -0
- package/dist/create-storage.d.ts +2 -0
- package/dist/create-storage.js +32 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/memory-storage.d.ts +6 -0
- package/dist/memory-storage.js +14 -0
- package/dist/storage-signal.d.ts +2 -0
- package/dist/storage-signal.js +94 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.js +1 -0
- package/index.html +61 -0
- package/package.json +30 -0
- package/src/create-storage.ts +41 -0
- package/src/index.ts +2 -0
- package/src/memory-storage.ts +16 -0
- package/src/storage-signal.test.ts +87 -0
- package/src/storage-signal.ts +145 -0
- package/src/types.ts +28 -0
- package/tsconfig.json +35 -0
- package/vitest.config.ts +8 -0
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,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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createStorage } from './create-storage.js';
|
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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,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
|
+
}
|