@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.
@@ -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'