@pyreon/storage 0.6.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/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +552 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +540 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +220 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +50 -0
- package/src/clear.ts +72 -0
- package/src/cookie.ts +165 -0
- package/src/custom.ts +128 -0
- package/src/index.ts +51 -0
- package/src/indexed-db.ts +205 -0
- package/src/local.ts +143 -0
- package/src/registry.ts +66 -0
- package/src/session.ts +58 -0
- package/src/tests/clear.test.ts +104 -0
- package/src/tests/cookie.test.ts +149 -0
- package/src/tests/custom.test.ts +207 -0
- package/src/tests/indexed-db.test.ts +74 -0
- package/src/tests/local.test.ts +215 -0
- package/src/tests/session.test.ts +67 -0
- package/src/types.ts +86 -0
- package/src/utils.ts +66 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Signal, signal } from "@pyreon/reactivity";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A signal backed by a storage backend. Behaves like a normal signal
|
|
6
|
+
* but persists writes to the underlying storage mechanism.
|
|
7
|
+
*/
|
|
8
|
+
interface StorageSignal<T> extends Signal<T> {
|
|
9
|
+
/** Remove the value from storage and reset to the default value */
|
|
10
|
+
remove(): void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Base options shared by all storage hooks.
|
|
14
|
+
*/
|
|
15
|
+
interface StorageOptions<T> {
|
|
16
|
+
/** Custom serializer — default: JSON.stringify */
|
|
17
|
+
serializer?: (value: T) => string;
|
|
18
|
+
/** Custom deserializer — default: JSON.parse */
|
|
19
|
+
deserializer?: (raw: string) => T;
|
|
20
|
+
/** Called when deserialization fails — returns fallback or void for default */
|
|
21
|
+
onError?: (error: Error) => T | undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Options for the useCookie hook.
|
|
25
|
+
*/
|
|
26
|
+
interface CookieOptions<T> extends StorageOptions<T> {
|
|
27
|
+
/** Max age in seconds */
|
|
28
|
+
maxAge?: number;
|
|
29
|
+
/** Expiry date (alternative to maxAge) */
|
|
30
|
+
expires?: Date;
|
|
31
|
+
/** Cookie path — default: '/' */
|
|
32
|
+
path?: string;
|
|
33
|
+
/** Cookie domain */
|
|
34
|
+
domain?: string;
|
|
35
|
+
/** HTTPS only — default: false */
|
|
36
|
+
secure?: boolean;
|
|
37
|
+
/** SameSite policy — default: 'lax' */
|
|
38
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Options for the useIndexedDB hook.
|
|
42
|
+
*/
|
|
43
|
+
interface IndexedDBOptions<T> extends StorageOptions<T> {
|
|
44
|
+
/** Database name — default: 'pyreon-storage' */
|
|
45
|
+
dbName?: string;
|
|
46
|
+
/** Object store name — default: 'kv' */
|
|
47
|
+
storeName?: string;
|
|
48
|
+
/** Write debounce in ms — default: 100 */
|
|
49
|
+
debounceMs?: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Interface for a custom storage backend used with createStorage.
|
|
53
|
+
*/
|
|
54
|
+
interface StorageBackend {
|
|
55
|
+
/** Read a raw string value by key. Return null if not found. */
|
|
56
|
+
get(key: string): string | null;
|
|
57
|
+
/** Write a raw string value by key */
|
|
58
|
+
set(key: string, value: string): void;
|
|
59
|
+
/** Remove a value by key */
|
|
60
|
+
remove(key: string): void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Async variant for backends like IndexedDB.
|
|
64
|
+
*/
|
|
65
|
+
interface AsyncStorageBackend {
|
|
66
|
+
/** Read a raw string value by key */
|
|
67
|
+
get(key: string): Promise<string | null>;
|
|
68
|
+
/** Write a raw string value by key */
|
|
69
|
+
set(key: string, value: string): Promise<void>;
|
|
70
|
+
/** Remove a value by key */
|
|
71
|
+
remove(key: string): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/cookie.d.ts
|
|
75
|
+
/**
|
|
76
|
+
* Set the cookie source string for SSR. Call this once per request
|
|
77
|
+
* with the raw Cookie header value.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* // In your SSR request handler
|
|
82
|
+
* setCookieSource(request.headers.get('cookie') ?? '')
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
declare function setCookieSource(cookieHeader: string): void;
|
|
86
|
+
/**
|
|
87
|
+
* Reactive signal backed by a browser cookie. SSR-compatible when
|
|
88
|
+
* used with setCookieSource().
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* const locale = useCookie('locale', 'en', {
|
|
93
|
+
* maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
94
|
+
* path: '/',
|
|
95
|
+
* sameSite: 'lax',
|
|
96
|
+
* })
|
|
97
|
+
* locale() // 'en'
|
|
98
|
+
* locale.set('de') // sets cookie + updates signal
|
|
99
|
+
* locale.remove() // deletes cookie, resets to default
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
declare function useCookie<T>(key: string, defaultValue: T, options?: CookieOptions<T>): StorageSignal<T>;
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/custom.d.ts
|
|
105
|
+
/**
|
|
106
|
+
* Create a custom storage hook backed by any synchronous storage backend.
|
|
107
|
+
* Useful for encrypted storage, in-memory storage, or custom adapters.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```ts
|
|
111
|
+
* const useEncrypted = createStorage({
|
|
112
|
+
* get: (key) => decrypt(localStorage.getItem(key)),
|
|
113
|
+
* set: (key, value) => localStorage.setItem(key, encrypt(value)),
|
|
114
|
+
* remove: (key) => localStorage.removeItem(key),
|
|
115
|
+
* })
|
|
116
|
+
*
|
|
117
|
+
* const secret = useEncrypted('api-key', '')
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
declare function createStorage(backend: StorageBackend, backendName?: string): <T>(key: string, defaultValue: T, options?: StorageOptions<T>) => StorageSignal<T>;
|
|
121
|
+
/**
|
|
122
|
+
* In-memory storage backend. Useful for SSR, testing, or ephemeral state.
|
|
123
|
+
* Values are lost on page unload.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { useMemoryStorage } from '@pyreon/storage'
|
|
128
|
+
*
|
|
129
|
+
* const temp = useMemoryStorage('key', 'default')
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
declare const useMemoryStorage: <T>(key: string, defaultValue: T, options?: StorageOptions<T>) => StorageSignal<T>;
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/indexed-db.d.ts
|
|
135
|
+
/**
|
|
136
|
+
* Reactive signal backed by IndexedDB. Suitable for large or structured
|
|
137
|
+
* data that exceeds localStorage limits. Writes are debounced.
|
|
138
|
+
*
|
|
139
|
+
* The signal starts with `defaultValue` and updates asynchronously
|
|
140
|
+
* when the stored value is read from IndexedDB.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* const draft = useIndexedDB('article-draft', { title: '', body: '' })
|
|
145
|
+
* draft() // { title: '', body: '' } initially, then stored value
|
|
146
|
+
* draft.set({ title: 'My Post', body: '...' }) // signal updates immediately, IDB write is debounced
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
declare function useIndexedDB<T>(key: string, defaultValue: T, options?: IndexedDBOptions<T>): StorageSignal<T>;
|
|
150
|
+
/**
|
|
151
|
+
* Reset the database cache. For testing only.
|
|
152
|
+
*/
|
|
153
|
+
declare function _resetDBCache(): void;
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/local.d.ts
|
|
156
|
+
/**
|
|
157
|
+
* Reactive signal backed by localStorage. Automatically syncs across
|
|
158
|
+
* browser tabs via the native `storage` event.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```ts
|
|
162
|
+
* const theme = useStorage('theme', 'light')
|
|
163
|
+
* theme() // 'light' (or stored value)
|
|
164
|
+
* theme.set('dark') // updates signal + localStorage
|
|
165
|
+
* theme.remove() // clears storage, resets to default
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
declare function useStorage<T>(key: string, defaultValue: T, options?: StorageOptions<T>): StorageSignal<T>;
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/session.d.ts
|
|
171
|
+
/**
|
|
172
|
+
* Reactive signal backed by sessionStorage. Scoped to the current
|
|
173
|
+
* browser tab — does not sync across tabs.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* const step = useSessionStorage('wizard-step', 0)
|
|
178
|
+
* step() // 0 (or stored value)
|
|
179
|
+
* step.set(3) // updates signal + sessionStorage
|
|
180
|
+
* step.remove() // clears storage, resets to default
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
declare function useSessionStorage<T>(key: string, defaultValue: T, options?: StorageOptions<T>): StorageSignal<T>;
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/clear.d.ts
|
|
186
|
+
type StorageType = 'local' | 'session' | 'cookie' | 'indexeddb' | 'all';
|
|
187
|
+
/**
|
|
188
|
+
* Remove a specific key from storage and reset its signal to the default value.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```ts
|
|
192
|
+
* removeStorage('theme') // from localStorage
|
|
193
|
+
* removeStorage('step', { type: 'session' }) // from sessionStorage
|
|
194
|
+
* removeStorage('locale', { type: 'cookie' }) // deletes cookie
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
declare function removeStorage(key: string, options?: {
|
|
198
|
+
type?: 'local' | 'session' | 'cookie' | 'indexeddb';
|
|
199
|
+
}): void;
|
|
200
|
+
/**
|
|
201
|
+
* Clear all managed storage entries for a specific backend, or all backends.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```ts
|
|
205
|
+
* clearStorage() // clear all localStorage entries managed by @pyreon/storage
|
|
206
|
+
* clearStorage('session') // clear all sessionStorage entries
|
|
207
|
+
* clearStorage('cookie') // clear all managed cookies
|
|
208
|
+
* clearStorage('all') // clear everything
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
declare function clearStorage(type?: StorageType): void;
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/registry.d.ts
|
|
214
|
+
/**
|
|
215
|
+
* Clear all entries from the registry. Used for testing.
|
|
216
|
+
*/
|
|
217
|
+
declare function _resetRegistry(): void;
|
|
218
|
+
//#endregion
|
|
219
|
+
export { type AsyncStorageBackend, type CookieOptions, type IndexedDBOptions, type StorageBackend, type StorageOptions, type StorageSignal, _resetDBCache, _resetRegistry, clearStorage, createStorage, removeStorage, setCookieSource, useCookie, useIndexedDB, useMemoryStorage, useSessionStorage, useStorage };
|
|
220
|
+
//# sourceMappingURL=index2.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/types.ts","../../src/cookie.ts","../../src/custom.ts","../../src/indexed-db.ts","../../src/local.ts","../../src/session.ts","../../src/clear.ts","../../src/registry.ts"],"mappings":";;;;;AAQA;;UAAiB,aAAA,YAAyB,MAAA,CAAO,CAAA;EAAD;EAE9C,MAAA;AAAA;;;;UAQe,cAAA;EAAA;EAEf,UAAA,IAAc,KAAA,EAAO,CAAA;EAFQ;EAI7B,YAAA,IAAgB,GAAA,aAAgB,CAAA;EAAA;EAEhC,OAAA,IAAW,KAAA,EAAO,KAAA,KAAU,CAAA;AAAA;;;;UAQb,aAAA,YAAyB,cAAA,CAAe,CAAA;EAZlC;EAcrB,MAAA;EAZA;EAcA,OAAA,GAAU,IAAA;EAdsB;EAgBhC,IAAA;EAdkB;EAgBlB,MAAA;EAhB4B;EAkB5B,MAAA;EAlB6B;EAoB7B,QAAA;AAAA;;;;UAQe,gBAAA,YAA4B,cAAA,CAAe,CAAA;EApBJ;EAsBtD,MAAA;EAtB6B;EAwB7B,SAAA;EAxBuD;EA0BvD,UAAA;AAAA;;;;UAQe,cAAA;EAtBf;EAwBA,GAAA,CAAI,GAAA;EAxBI;EA0BR,GAAA,CAAI,GAAA,UAAa,KAAA;EAlBc;EAoB/B,MAAA,CAAO,GAAA;AAAA;;;;UAMQ,mBAAA;EAtBf;EAwBA,GAAA,CAAI,GAAA,WAAc,OAAA;EAtBR;EAwBV,GAAA,CAAI,GAAA,UAAa,KAAA,WAAgB,OAAA;EAhBlB;EAkBf,MAAA,CAAO,GAAA,WAAc,OAAA;AAAA;;;;;AA5EvB;;;;;;;;iBCWgB,eAAA,CAAgB,YAAA;;ADDhC;;;;;;;;;;;;;;;iBC8FgB,SAAA,GAAA,CACd,GAAA,UACA,YAAA,EAAc,CAAA,EACd,OAAA,GAAS,aAAA,CAAc,CAAA,IACtB,aAAA,CAAc,CAAA;;;;;AD5GjB;;;;;;;;;;AAUA;;;iBEIgB,aAAA,CACd,OAAA,EAAS,cAAA,EACT,WAAA,gBAEA,GAAA,UACA,YAAA,EAAc,CAAA,EACd,OAAA,GAAU,cAAA,CAAe,CAAA,MACtB,aAAA,CAAc,CAAA;;;;;;;;;;;;cAwFN,gBAAA,MA5FR,GAAA,UACQ,YAAA,EACG,CAAA,EAAC,OAAA,GACL,cAAA,CAAe,CAAA,MACtB,aAAA,CAAc,CAAA;;;;;AFrBnB;;;;;;;;;;AAUA;;iBG0EgB,YAAA,GAAA,CACd,GAAA,UACA,YAAA,EAAc,CAAA,EACd,OAAA,GAAS,gBAAA,CAAiB,CAAA,IACzB,aAAA,CAAc,CAAA;;;;iBA0GD,aAAA,CAAA;;;;AHlMhB;;;;;;;;;;AAUA;iBIuBgB,UAAA,GAAA,CACd,GAAA,UACA,YAAA,EAAc,CAAA,EACd,OAAA,GAAU,cAAA,CAAe,CAAA,IACxB,aAAA,CAAc,CAAA;;;;;AJrCjB;;;;;;;;;;iBKYgB,iBAAA,GAAA,CACd,GAAA,UACA,YAAA,EAAc,CAAA,EACd,OAAA,GAAU,cAAA,CAAe,CAAA,IACxB,aAAA,CAAc,CAAA;;;KCnBZ,WAAA;;;ANGL;;;;;;;;iBMWgB,aAAA,CACd,GAAA,UACA,OAAA;EAAY,IAAA;AAAA;;;;;;;;;;;;iBAiCE,YAAA,CAAa,IAAA,GAAM,WAAA;;;;;;iBCSnB,cAAA,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pyreon/storage",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Reactive client-side storage for Pyreon — localStorage, sessionStorage, cookies, IndexedDB",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/pyreon/fundamentals.git",
|
|
9
|
+
"directory": "packages/storage"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/pyreon/fundamentals/tree/main/packages/storage#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/pyreon/fundamentals/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./lib/index.js",
|
|
26
|
+
"module": "./lib/index.js",
|
|
27
|
+
"types": "./lib/types/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"import": "./lib/index.js",
|
|
32
|
+
"types": "./lib/types/index.d.ts"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "vl_rolldown_build",
|
|
38
|
+
"dev": "vl_rolldown_build-watch",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@pyreon/reactivity": ">=0.5.0 <1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@happy-dom/global-registrator": "^20.8.3",
|
|
47
|
+
"@pyreon/reactivity": ">=0.5.0 <1.0.0",
|
|
48
|
+
"@vitus-labs/tools-lint": "^1.11.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/clear.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { getEntriesByBackend, getEntry, removeEntry } from './registry'
|
|
2
|
+
import { getWebStorage, isBrowser } from './utils'
|
|
3
|
+
|
|
4
|
+
// ─── Storage type mapping ────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
type StorageType = 'local' | 'session' | 'cookie' | 'indexeddb' | 'all'
|
|
7
|
+
|
|
8
|
+
// ─── removeStorage ───────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Remove a specific key from storage and reset its signal to the default value.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* removeStorage('theme') // from localStorage
|
|
16
|
+
* removeStorage('step', { type: 'session' }) // from sessionStorage
|
|
17
|
+
* removeStorage('locale', { type: 'cookie' }) // deletes cookie
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function removeStorage(
|
|
21
|
+
key: string,
|
|
22
|
+
options?: { type?: 'local' | 'session' | 'cookie' | 'indexeddb' },
|
|
23
|
+
): void {
|
|
24
|
+
const type = options?.type ?? 'local'
|
|
25
|
+
const entry = getEntry(type, key)
|
|
26
|
+
|
|
27
|
+
if (entry) {
|
|
28
|
+
entry.signal.remove()
|
|
29
|
+
} else {
|
|
30
|
+
// No signal registered — still try to clear the raw storage
|
|
31
|
+
if (type === 'local' || type === 'session') {
|
|
32
|
+
const storage = getWebStorage(type)
|
|
33
|
+
if (storage) storage.removeItem(key)
|
|
34
|
+
} else if (type === 'cookie' && isBrowser()) {
|
|
35
|
+
// biome-ignore lint/suspicious/noDocumentCookie: standard cookie deletion API
|
|
36
|
+
document.cookie = `${encodeURIComponent(key)}=; max-age=0; path=/`
|
|
37
|
+
}
|
|
38
|
+
removeEntry(type, key)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── clearStorage ────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Clear all managed storage entries for a specific backend, or all backends.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* clearStorage() // clear all localStorage entries managed by @pyreon/storage
|
|
50
|
+
* clearStorage('session') // clear all sessionStorage entries
|
|
51
|
+
* clearStorage('cookie') // clear all managed cookies
|
|
52
|
+
* clearStorage('all') // clear everything
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function clearStorage(type: StorageType = 'local'): void {
|
|
56
|
+
if (type === 'all') {
|
|
57
|
+
clearBackend('local')
|
|
58
|
+
clearBackend('session')
|
|
59
|
+
clearBackend('cookie')
|
|
60
|
+
clearBackend('indexeddb')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clearBackend(type)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clearBackend(type: string): void {
|
|
68
|
+
const entries = getEntriesByBackend(type)
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
entry.signal.remove()
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/cookie.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { getEntry, removeEntry, setEntry } from './registry'
|
|
3
|
+
import type { CookieOptions, StorageSignal } from './types'
|
|
4
|
+
import { deserialize, isBrowser, serialize } from './utils'
|
|
5
|
+
|
|
6
|
+
// ─── Server-side cookie source ───────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
let serverCookieString = ''
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Set the cookie source string for SSR. Call this once per request
|
|
12
|
+
* with the raw Cookie header value.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* // In your SSR request handler
|
|
17
|
+
* setCookieSource(request.headers.get('cookie') ?? '')
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function setCookieSource(cookieHeader: string): void {
|
|
21
|
+
serverCookieString = cookieHeader
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Cookie parsing ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function parseCookies(cookieString: string): Map<string, string> {
|
|
27
|
+
const cookies = new Map<string, string>()
|
|
28
|
+
if (!cookieString) return cookies
|
|
29
|
+
|
|
30
|
+
for (const pair of cookieString.split(';')) {
|
|
31
|
+
const eqIndex = pair.indexOf('=')
|
|
32
|
+
if (eqIndex === -1) continue
|
|
33
|
+
const name = pair.slice(0, eqIndex).trim()
|
|
34
|
+
const value = pair.slice(eqIndex + 1).trim()
|
|
35
|
+
if (name) cookies.set(name, decodeURIComponent(value))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return cookies
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getCookieString(): string {
|
|
42
|
+
if (isBrowser()) return document.cookie
|
|
43
|
+
return serverCookieString
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readCookie(key: string): string | null {
|
|
47
|
+
const cookies = parseCookies(getCookieString())
|
|
48
|
+
return cookies.get(key) ?? null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Cookie writing ──────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function writeCookie<T>(
|
|
54
|
+
key: string,
|
|
55
|
+
value: T,
|
|
56
|
+
options: CookieOptions<T>,
|
|
57
|
+
): void {
|
|
58
|
+
if (!isBrowser()) return
|
|
59
|
+
|
|
60
|
+
const serialized = serialize(value, options.serializer)
|
|
61
|
+
let cookie = `${encodeURIComponent(key)}=${encodeURIComponent(serialized)}`
|
|
62
|
+
|
|
63
|
+
if (options.maxAge !== undefined) {
|
|
64
|
+
cookie += `; max-age=${options.maxAge}`
|
|
65
|
+
}
|
|
66
|
+
if (options.expires) {
|
|
67
|
+
cookie += `; expires=${options.expires.toUTCString()}`
|
|
68
|
+
}
|
|
69
|
+
cookie += `; path=${options.path ?? '/'}`
|
|
70
|
+
if (options.domain) {
|
|
71
|
+
cookie += `; domain=${options.domain}`
|
|
72
|
+
}
|
|
73
|
+
if (options.secure) {
|
|
74
|
+
cookie += '; secure'
|
|
75
|
+
}
|
|
76
|
+
cookie += `; samesite=${options.sameSite ?? 'lax'}`
|
|
77
|
+
|
|
78
|
+
// biome-ignore lint/suspicious/noDocumentCookie: document.cookie is the standard cookie write API
|
|
79
|
+
document.cookie = cookie
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function deleteCookie<T>(key: string, options: CookieOptions<T>): void {
|
|
83
|
+
if (!isBrowser()) return
|
|
84
|
+
|
|
85
|
+
let cookie = `${encodeURIComponent(key)}=; max-age=0`
|
|
86
|
+
cookie += `; path=${options.path ?? '/'}`
|
|
87
|
+
if (options.domain) {
|
|
88
|
+
cookie += `; domain=${options.domain}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// biome-ignore lint/suspicious/noDocumentCookie: document.cookie is the standard cookie write API
|
|
92
|
+
document.cookie = cookie
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── useCookie ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reactive signal backed by a browser cookie. SSR-compatible when
|
|
99
|
+
* used with setCookieSource().
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* const locale = useCookie('locale', 'en', {
|
|
104
|
+
* maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
105
|
+
* path: '/',
|
|
106
|
+
* sameSite: 'lax',
|
|
107
|
+
* })
|
|
108
|
+
* locale() // 'en'
|
|
109
|
+
* locale.set('de') // sets cookie + updates signal
|
|
110
|
+
* locale.remove() // deletes cookie, resets to default
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function useCookie<T>(
|
|
114
|
+
key: string,
|
|
115
|
+
defaultValue: T,
|
|
116
|
+
options: CookieOptions<T> = {},
|
|
117
|
+
): StorageSignal<T> {
|
|
118
|
+
// Return existing signal if already registered
|
|
119
|
+
const existing = getEntry<T>('cookie', key)
|
|
120
|
+
if (existing) return existing.signal
|
|
121
|
+
|
|
122
|
+
// Read initial value from cookie
|
|
123
|
+
const raw = readCookie(key)
|
|
124
|
+
const initialValue =
|
|
125
|
+
raw !== null
|
|
126
|
+
? deserialize(raw, defaultValue, options.deserializer, options.onError)
|
|
127
|
+
: defaultValue
|
|
128
|
+
|
|
129
|
+
const sig = signal<T>(initialValue)
|
|
130
|
+
|
|
131
|
+
// Build the storage signal
|
|
132
|
+
const storageSig = (() => sig()) as unknown as StorageSignal<T>
|
|
133
|
+
|
|
134
|
+
storageSig.peek = () => sig.peek()
|
|
135
|
+
storageSig.subscribe = (listener: () => void) => sig.subscribe(listener)
|
|
136
|
+
storageSig.direct = (updater: () => void) => sig.direct(updater)
|
|
137
|
+
storageSig.debug = () => sig.debug()
|
|
138
|
+
|
|
139
|
+
Object.defineProperty(storageSig, 'label', {
|
|
140
|
+
get: () => sig.label,
|
|
141
|
+
set: (v: string | undefined) => {
|
|
142
|
+
sig.label = v
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
storageSig.set = (value: T) => {
|
|
147
|
+
sig.set(value)
|
|
148
|
+
writeCookie(key, value, options)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
storageSig.update = (fn: (current: T) => T) => {
|
|
152
|
+
const newValue = fn(sig.peek())
|
|
153
|
+
storageSig.set(newValue)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
storageSig.remove = () => {
|
|
157
|
+
sig.set(defaultValue)
|
|
158
|
+
deleteCookie(key, options)
|
|
159
|
+
removeEntry('cookie', key)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
setEntry('cookie', key, storageSig, defaultValue)
|
|
163
|
+
|
|
164
|
+
return storageSig
|
|
165
|
+
}
|
package/src/custom.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { getEntry, removeEntry, setEntry } from './registry'
|
|
3
|
+
import type { StorageBackend, StorageOptions, StorageSignal } from './types'
|
|
4
|
+
import { deserialize, serialize } from './utils'
|
|
5
|
+
|
|
6
|
+
// ─── createStorage ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a custom storage hook backed by any synchronous storage backend.
|
|
10
|
+
* Useful for encrypted storage, in-memory storage, or custom adapters.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const useEncrypted = createStorage({
|
|
15
|
+
* get: (key) => decrypt(localStorage.getItem(key)),
|
|
16
|
+
* set: (key, value) => localStorage.setItem(key, encrypt(value)),
|
|
17
|
+
* remove: (key) => localStorage.removeItem(key),
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* const secret = useEncrypted('api-key', '')
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function createStorage(
|
|
24
|
+
backend: StorageBackend,
|
|
25
|
+
backendName?: string,
|
|
26
|
+
): <T>(
|
|
27
|
+
key: string,
|
|
28
|
+
defaultValue: T,
|
|
29
|
+
options?: StorageOptions<T>,
|
|
30
|
+
) => StorageSignal<T> {
|
|
31
|
+
const name = backendName ?? 'custom'
|
|
32
|
+
|
|
33
|
+
return function useCustomStorage<T>(
|
|
34
|
+
key: string,
|
|
35
|
+
defaultValue: T,
|
|
36
|
+
options?: StorageOptions<T>,
|
|
37
|
+
): StorageSignal<T> {
|
|
38
|
+
// Return existing signal if already registered
|
|
39
|
+
const existing = getEntry<T>(name, key)
|
|
40
|
+
if (existing) return existing.signal
|
|
41
|
+
|
|
42
|
+
// Read initial value
|
|
43
|
+
let initialValue = defaultValue
|
|
44
|
+
try {
|
|
45
|
+
const raw = backend.get(key)
|
|
46
|
+
if (raw !== null) {
|
|
47
|
+
initialValue = deserialize(
|
|
48
|
+
raw,
|
|
49
|
+
defaultValue,
|
|
50
|
+
options?.deserializer,
|
|
51
|
+
options?.onError,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Backend read failed — use default
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sig = signal<T>(initialValue)
|
|
59
|
+
|
|
60
|
+
// Build the storage signal
|
|
61
|
+
const storageSig = (() => sig()) as unknown as StorageSignal<T>
|
|
62
|
+
|
|
63
|
+
storageSig.peek = () => sig.peek()
|
|
64
|
+
storageSig.subscribe = (listener: () => void) => sig.subscribe(listener)
|
|
65
|
+
storageSig.direct = (updater: () => void) => sig.direct(updater)
|
|
66
|
+
storageSig.debug = () => sig.debug()
|
|
67
|
+
|
|
68
|
+
Object.defineProperty(storageSig, 'label', {
|
|
69
|
+
get: () => sig.label,
|
|
70
|
+
set: (v: string | undefined) => {
|
|
71
|
+
sig.label = v
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
storageSig.set = (value: T) => {
|
|
76
|
+
sig.set(value)
|
|
77
|
+
try {
|
|
78
|
+
backend.set(key, serialize(value, options?.serializer))
|
|
79
|
+
} catch {
|
|
80
|
+
// Write failed — signal still updates
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
storageSig.update = (fn: (current: T) => T) => {
|
|
85
|
+
const newValue = fn(sig.peek())
|
|
86
|
+
storageSig.set(newValue)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
storageSig.remove = () => {
|
|
90
|
+
sig.set(defaultValue)
|
|
91
|
+
try {
|
|
92
|
+
backend.remove(key)
|
|
93
|
+
} catch {
|
|
94
|
+
// Remove failed
|
|
95
|
+
}
|
|
96
|
+
removeEntry(name, key)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setEntry(name, key, storageSig, defaultValue)
|
|
100
|
+
|
|
101
|
+
return storageSig
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Memory storage ──────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* In-memory storage backend. Useful for SSR, testing, or ephemeral state.
|
|
109
|
+
* Values are lost on page unload.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* import { useMemoryStorage } from '@pyreon/storage'
|
|
114
|
+
*
|
|
115
|
+
* const temp = useMemoryStorage('key', 'default')
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export const useMemoryStorage = createStorage(
|
|
119
|
+
(() => {
|
|
120
|
+
const store = new Map<string, string>()
|
|
121
|
+
return {
|
|
122
|
+
get: (key: string) => store.get(key) ?? null,
|
|
123
|
+
set: (key: string, value: string) => store.set(key, value),
|
|
124
|
+
remove: (key: string) => store.delete(key),
|
|
125
|
+
}
|
|
126
|
+
})(),
|
|
127
|
+
'memory',
|
|
128
|
+
)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyreon/storage — Reactive client-side storage for Pyreon.
|
|
3
|
+
*
|
|
4
|
+
* Signal-backed persistence across localStorage, sessionStorage, cookies,
|
|
5
|
+
* IndexedDB, and custom backends. Every stored value is a reactive signal
|
|
6
|
+
* that persists writes automatically.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { useStorage, useCookie, useIndexedDB } from '@pyreon/storage'
|
|
11
|
+
*
|
|
12
|
+
* // localStorage — persistent, cross-tab synced
|
|
13
|
+
* const theme = useStorage('theme', 'light')
|
|
14
|
+
* theme() // read reactively
|
|
15
|
+
* theme.set('dark') // updates signal + localStorage
|
|
16
|
+
*
|
|
17
|
+
* // Cookie — SSR-readable, configurable expiry
|
|
18
|
+
* const locale = useCookie('locale', 'en', { maxAge: 365 * 86400 })
|
|
19
|
+
*
|
|
20
|
+
* // IndexedDB — large data, debounced writes
|
|
21
|
+
* const draft = useIndexedDB('article-draft', { title: '', body: '' })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export { setCookieSource, useCookie } from './cookie'
|
|
28
|
+
export { createStorage, useMemoryStorage } from './custom'
|
|
29
|
+
export { useIndexedDB } from './indexed-db'
|
|
30
|
+
export { useStorage } from './local'
|
|
31
|
+
export { useSessionStorage } from './session'
|
|
32
|
+
|
|
33
|
+
// ─── Utilities ───────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export { clearStorage, removeStorage } from './clear'
|
|
36
|
+
|
|
37
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export type {
|
|
40
|
+
AsyncStorageBackend,
|
|
41
|
+
CookieOptions,
|
|
42
|
+
IndexedDBOptions,
|
|
43
|
+
StorageBackend,
|
|
44
|
+
StorageOptions,
|
|
45
|
+
StorageSignal,
|
|
46
|
+
} from './types'
|
|
47
|
+
|
|
48
|
+
// ─── Testing ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export { _resetDBCache } from './indexed-db'
|
|
51
|
+
export { _resetRegistry } from './registry'
|