react-native-nitro-storage 0.1.4 → 0.3.1
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/README.md +432 -345
- package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +191 -3
- package/android/src/main/cpp/AndroidStorageAdapterCpp.hpp +21 -41
- package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +181 -29
- package/android/src/main/java/com/nitrostorage/NitroStoragePackage.kt +2 -2
- package/app.plugin.js +9 -7
- package/cpp/bindings/HybridStorage.cpp +239 -10
- package/cpp/bindings/HybridStorage.hpp +10 -0
- package/cpp/core/NativeStorageAdapter.hpp +22 -0
- package/ios/IOSStorageAdapterCpp.hpp +25 -0
- package/ios/IOSStorageAdapterCpp.mm +315 -33
- package/lib/commonjs/Storage.types.js +23 -1
- package/lib/commonjs/Storage.types.js.map +1 -1
- package/lib/commonjs/index.js +680 -68
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +801 -133
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/internal.js +112 -0
- package/lib/commonjs/internal.js.map +1 -0
- package/lib/module/Storage.types.js +22 -0
- package/lib/module/Storage.types.js.map +1 -1
- package/lib/module/index.js +660 -71
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +766 -125
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/internal.js +100 -0
- package/lib/module/internal.js.map +1 -0
- package/lib/typescript/Storage.nitro.d.ts +10 -0
- package/lib/typescript/Storage.nitro.d.ts.map +1 -1
- package/lib/typescript/Storage.types.d.ts +20 -0
- package/lib/typescript/Storage.types.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +68 -9
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +79 -13
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/internal.d.ts +21 -0
- package/lib/typescript/internal.d.ts.map +1 -0
- package/lib/typescript/migration.d.ts +2 -3
- package/lib/typescript/migration.d.ts.map +1 -1
- package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +10 -0
- package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +10 -0
- package/package.json +22 -8
- package/src/Storage.nitro.ts +11 -2
- package/src/Storage.types.ts +22 -0
- package/src/index.ts +943 -84
- package/src/index.web.ts +1082 -137
- package/src/internal.ts +144 -0
- package/src/migration.ts +3 -3
package/src/index.web.ts
CHANGED
|
@@ -1,37 +1,267 @@
|
|
|
1
|
-
import { useSyncExternalStore } from "react";
|
|
1
|
+
import { useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
|
|
3
|
+
import {
|
|
4
|
+
MIGRATION_VERSION_KEY,
|
|
5
|
+
type StoredEnvelope,
|
|
6
|
+
isStoredEnvelope,
|
|
7
|
+
assertBatchScope,
|
|
8
|
+
assertValidScope,
|
|
9
|
+
serializeWithPrimitiveFastPath,
|
|
10
|
+
deserializeWithPrimitiveFastPath,
|
|
11
|
+
prefixKey,
|
|
12
|
+
isNamespaced,
|
|
13
|
+
} from "./internal";
|
|
2
14
|
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
export { StorageScope, AccessControl, BiometricLevel } from "./Storage.types";
|
|
16
|
+
export { migrateFromMMKV } from "./migration";
|
|
17
|
+
|
|
18
|
+
export type Validator<T> = (value: unknown) => value is T;
|
|
19
|
+
export type ExpirationConfig = {
|
|
20
|
+
ttlMs: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type MigrationContext = {
|
|
24
|
+
scope: StorageScope;
|
|
25
|
+
getRaw: (key: string) => string | undefined;
|
|
26
|
+
setRaw: (key: string, value: string) => void;
|
|
27
|
+
removeRaw: (key: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type Migration = (context: MigrationContext) => void;
|
|
31
|
+
|
|
32
|
+
export type TransactionContext = {
|
|
33
|
+
scope: StorageScope;
|
|
34
|
+
getRaw: (key: string) => string | undefined;
|
|
35
|
+
setRaw: (key: string, value: string) => void;
|
|
36
|
+
removeRaw: (key: string) => void;
|
|
37
|
+
getItem: <T>(item: Pick<StorageItem<T>, "scope" | "key" | "get">) => T;
|
|
38
|
+
setItem: <T>(
|
|
39
|
+
item: Pick<StorageItem<T>, "scope" | "key" | "set">,
|
|
40
|
+
value: T,
|
|
41
|
+
) => void;
|
|
42
|
+
removeItem: (
|
|
43
|
+
item: Pick<StorageItem<unknown>, "scope" | "key" | "delete">,
|
|
44
|
+
) => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type KeyListenerRegistry = Map<string, Set<() => void>>;
|
|
48
|
+
type RawBatchPathItem = {
|
|
49
|
+
_hasValidation?: boolean;
|
|
50
|
+
_hasExpiration?: boolean;
|
|
51
|
+
_isBiometric?: boolean;
|
|
52
|
+
_secureAccessControl?: AccessControl;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function asInternal(item: StorageItem<any>): StorageItemInternal<any> {
|
|
56
|
+
return item as unknown as StorageItemInternal<any>;
|
|
7
57
|
}
|
|
58
|
+
type NonMemoryScope = StorageScope.Disk | StorageScope.Secure;
|
|
59
|
+
type PendingSecureWrite = { key: string; value: string | undefined };
|
|
60
|
+
type BrowserStorageLike = {
|
|
61
|
+
setItem: (key: string, value: string) => void;
|
|
62
|
+
getItem: (key: string) => string | null;
|
|
63
|
+
removeItem: (key: string) => void;
|
|
64
|
+
clear: () => void;
|
|
65
|
+
key: (index: number) => string | null;
|
|
66
|
+
readonly length: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const registeredMigrations = new Map<number, Migration>();
|
|
70
|
+
const runMicrotask =
|
|
71
|
+
typeof queueMicrotask === "function"
|
|
72
|
+
? queueMicrotask
|
|
73
|
+
: (task: () => void) => {
|
|
74
|
+
Promise.resolve().then(task);
|
|
75
|
+
};
|
|
8
76
|
|
|
9
77
|
export interface Storage {
|
|
10
78
|
name: string;
|
|
11
|
-
equals: (other:
|
|
79
|
+
equals: (other: unknown) => boolean;
|
|
12
80
|
dispose: () => void;
|
|
13
81
|
set(key: string, value: string, scope: number): void;
|
|
14
82
|
get(key: string, scope: number): string | undefined;
|
|
15
83
|
remove(key: string, scope: number): void;
|
|
16
84
|
clear(scope: number): void;
|
|
85
|
+
has(key: string, scope: number): boolean;
|
|
86
|
+
getAllKeys(scope: number): string[];
|
|
87
|
+
size(scope: number): number;
|
|
17
88
|
setBatch(keys: string[], values: string[], scope: number): void;
|
|
18
89
|
getBatch(keys: string[], scope: number): (string | undefined)[];
|
|
19
90
|
removeBatch(keys: string[], scope: number): void;
|
|
20
91
|
addOnChange(
|
|
21
92
|
scope: number,
|
|
22
|
-
callback: (key: string, value: string | undefined) => void
|
|
93
|
+
callback: (key: string, value: string | undefined) => void,
|
|
23
94
|
): () => void;
|
|
95
|
+
setSecureAccessControl(level: number): void;
|
|
96
|
+
setKeychainAccessGroup(group: string): void;
|
|
97
|
+
setSecureBiometric(key: string, value: string): void;
|
|
98
|
+
getSecureBiometric(key: string): string | undefined;
|
|
99
|
+
deleteSecureBiometric(key: string): void;
|
|
100
|
+
hasSecureBiometric(key: string): boolean;
|
|
101
|
+
clearSecureBiometric(): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const memoryStore = new Map<string, unknown>();
|
|
105
|
+
const memoryListeners: KeyListenerRegistry = new Map();
|
|
106
|
+
const webScopeListeners = new Map<NonMemoryScope, KeyListenerRegistry>([
|
|
107
|
+
[StorageScope.Disk, new Map()],
|
|
108
|
+
[StorageScope.Secure, new Map()],
|
|
109
|
+
]);
|
|
110
|
+
const scopedRawCache = new Map<NonMemoryScope, Map<string, string | undefined>>(
|
|
111
|
+
[
|
|
112
|
+
[StorageScope.Disk, new Map()],
|
|
113
|
+
[StorageScope.Secure, new Map()],
|
|
114
|
+
],
|
|
115
|
+
);
|
|
116
|
+
const pendingSecureWrites = new Map<string, PendingSecureWrite>();
|
|
117
|
+
let secureFlushScheduled = false;
|
|
118
|
+
const SECURE_WEB_PREFIX = "__secure_";
|
|
119
|
+
const BIOMETRIC_WEB_PREFIX = "__bio_";
|
|
120
|
+
let hasWarnedAboutWebBiometricFallback = false;
|
|
121
|
+
|
|
122
|
+
function getBrowserStorage(scope: number): BrowserStorageLike | undefined {
|
|
123
|
+
if (scope === StorageScope.Disk) {
|
|
124
|
+
return globalThis.localStorage;
|
|
125
|
+
}
|
|
126
|
+
if (scope === StorageScope.Secure) {
|
|
127
|
+
return globalThis.localStorage;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
24
130
|
}
|
|
25
131
|
|
|
26
|
-
|
|
27
|
-
|
|
132
|
+
function toSecureStorageKey(key: string): string {
|
|
133
|
+
return `${SECURE_WEB_PREFIX}${key}`;
|
|
134
|
+
}
|
|
28
135
|
|
|
29
|
-
function
|
|
30
|
-
|
|
136
|
+
function fromSecureStorageKey(key: string): string {
|
|
137
|
+
return key.slice(SECURE_WEB_PREFIX.length);
|
|
31
138
|
}
|
|
32
139
|
|
|
33
|
-
function
|
|
34
|
-
|
|
140
|
+
function toBiometricStorageKey(key: string): string {
|
|
141
|
+
return `${BIOMETRIC_WEB_PREFIX}${key}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function fromBiometricStorageKey(key: string): string {
|
|
145
|
+
return key.slice(BIOMETRIC_WEB_PREFIX.length);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getScopedListeners(scope: NonMemoryScope): KeyListenerRegistry {
|
|
149
|
+
return webScopeListeners.get(scope)!;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getScopeRawCache(
|
|
153
|
+
scope: NonMemoryScope,
|
|
154
|
+
): Map<string, string | undefined> {
|
|
155
|
+
return scopedRawCache.get(scope)!;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function cacheRawValue(
|
|
159
|
+
scope: NonMemoryScope,
|
|
160
|
+
key: string,
|
|
161
|
+
value: string | undefined,
|
|
162
|
+
): void {
|
|
163
|
+
getScopeRawCache(scope).set(key, value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readCachedRawValue(
|
|
167
|
+
scope: NonMemoryScope,
|
|
168
|
+
key: string,
|
|
169
|
+
): string | undefined {
|
|
170
|
+
return getScopeRawCache(scope).get(key);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function hasCachedRawValue(scope: NonMemoryScope, key: string): boolean {
|
|
174
|
+
return getScopeRawCache(scope).has(key);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function clearScopeRawCache(scope: NonMemoryScope): void {
|
|
178
|
+
getScopeRawCache(scope).clear();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function notifyKeyListeners(registry: KeyListenerRegistry, key: string): void {
|
|
182
|
+
registry.get(key)?.forEach((listener) => listener());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function notifyAllListeners(registry: KeyListenerRegistry): void {
|
|
186
|
+
registry.forEach((listeners) => {
|
|
187
|
+
listeners.forEach((listener) => listener());
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function addKeyListener(
|
|
192
|
+
registry: KeyListenerRegistry,
|
|
193
|
+
key: string,
|
|
194
|
+
listener: () => void,
|
|
195
|
+
): () => void {
|
|
196
|
+
let listeners = registry.get(key);
|
|
197
|
+
if (!listeners) {
|
|
198
|
+
listeners = new Set();
|
|
199
|
+
registry.set(key, listeners);
|
|
200
|
+
}
|
|
201
|
+
listeners.add(listener);
|
|
202
|
+
|
|
203
|
+
return () => {
|
|
204
|
+
const scopedListeners = registry.get(key);
|
|
205
|
+
if (!scopedListeners) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
scopedListeners.delete(listener);
|
|
209
|
+
if (scopedListeners.size === 0) {
|
|
210
|
+
registry.delete(key);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readPendingSecureWrite(key: string): string | undefined {
|
|
216
|
+
return pendingSecureWrites.get(key)?.value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function hasPendingSecureWrite(key: string): boolean {
|
|
220
|
+
return pendingSecureWrites.has(key);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function clearPendingSecureWrite(key: string): void {
|
|
224
|
+
pendingSecureWrites.delete(key);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function flushSecureWrites(): void {
|
|
228
|
+
secureFlushScheduled = false;
|
|
229
|
+
|
|
230
|
+
if (pendingSecureWrites.size === 0) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const writes = Array.from(pendingSecureWrites.values());
|
|
235
|
+
pendingSecureWrites.clear();
|
|
236
|
+
|
|
237
|
+
const keysToSet: string[] = [];
|
|
238
|
+
const valuesToSet: string[] = [];
|
|
239
|
+
const keysToRemove: string[] = [];
|
|
240
|
+
|
|
241
|
+
writes.forEach(({ key, value }) => {
|
|
242
|
+
if (value === undefined) {
|
|
243
|
+
keysToRemove.push(key);
|
|
244
|
+
} else {
|
|
245
|
+
keysToSet.push(key);
|
|
246
|
+
valuesToSet.push(value);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (keysToSet.length > 0) {
|
|
251
|
+
WebStorage.setBatch(keysToSet, valuesToSet, StorageScope.Secure);
|
|
252
|
+
}
|
|
253
|
+
if (keysToRemove.length > 0) {
|
|
254
|
+
WebStorage.removeBatch(keysToRemove, StorageScope.Secure);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function scheduleSecureWrite(key: string, value: string | undefined): void {
|
|
259
|
+
pendingSecureWrites.set(key, { key, value });
|
|
260
|
+
if (secureFlushScheduled) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
secureFlushScheduled = true;
|
|
264
|
+
runMicrotask(flushSecureWrites);
|
|
35
265
|
}
|
|
36
266
|
|
|
37
267
|
const WebStorage: Storage = {
|
|
@@ -39,77 +269,277 @@ const WebStorage: Storage = {
|
|
|
39
269
|
equals: (other) => other === WebStorage,
|
|
40
270
|
dispose: () => {},
|
|
41
271
|
set: (key: string, value: string, scope: number) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
272
|
+
const storage = getBrowserStorage(scope);
|
|
273
|
+
if (!storage) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const storageKey =
|
|
277
|
+
scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
|
|
278
|
+
storage.setItem(storageKey, value);
|
|
279
|
+
if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
|
|
280
|
+
notifyKeyListeners(getScopedListeners(scope), key);
|
|
48
281
|
}
|
|
49
282
|
},
|
|
50
|
-
|
|
51
283
|
get: (key: string, scope: number) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
return undefined;
|
|
284
|
+
const storage = getBrowserStorage(scope);
|
|
285
|
+
const storageKey =
|
|
286
|
+
scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
|
|
287
|
+
return storage?.getItem(storageKey) ?? undefined;
|
|
58
288
|
},
|
|
59
289
|
remove: (key: string, scope: number) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
290
|
+
const storage = getBrowserStorage(scope);
|
|
291
|
+
if (!storage) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (scope === StorageScope.Secure) {
|
|
295
|
+
storage.removeItem(toSecureStorageKey(key));
|
|
296
|
+
storage.removeItem(toBiometricStorageKey(key));
|
|
297
|
+
} else {
|
|
298
|
+
storage.removeItem(key);
|
|
299
|
+
}
|
|
300
|
+
if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
|
|
301
|
+
notifyKeyListeners(getScopedListeners(scope), key);
|
|
66
302
|
}
|
|
67
303
|
},
|
|
68
|
-
|
|
69
304
|
clear: (scope: number) => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
305
|
+
const storage = getBrowserStorage(scope);
|
|
306
|
+
if (!storage) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (scope === StorageScope.Secure) {
|
|
310
|
+
const keysToRemove: string[] = [];
|
|
311
|
+
for (let i = 0; i < storage.length; i++) {
|
|
312
|
+
const key = storage.key(i);
|
|
313
|
+
if (
|
|
314
|
+
key?.startsWith(SECURE_WEB_PREFIX) ||
|
|
315
|
+
key?.startsWith(BIOMETRIC_WEB_PREFIX)
|
|
316
|
+
) {
|
|
317
|
+
keysToRemove.push(key);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
keysToRemove.forEach((key) => storage.removeItem(key));
|
|
321
|
+
} else if (scope === StorageScope.Disk) {
|
|
322
|
+
const keysToRemove: string[] = [];
|
|
323
|
+
for (let i = 0; i < storage.length; i++) {
|
|
324
|
+
const key = storage.key(i);
|
|
325
|
+
if (
|
|
326
|
+
key &&
|
|
327
|
+
!key.startsWith(SECURE_WEB_PREFIX) &&
|
|
328
|
+
!key.startsWith(BIOMETRIC_WEB_PREFIX)
|
|
329
|
+
) {
|
|
330
|
+
keysToRemove.push(key);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
keysToRemove.forEach((key) => storage.removeItem(key));
|
|
334
|
+
} else {
|
|
335
|
+
storage.clear();
|
|
336
|
+
}
|
|
337
|
+
if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
|
|
338
|
+
notifyAllListeners(getScopedListeners(scope));
|
|
80
339
|
}
|
|
81
340
|
},
|
|
82
341
|
setBatch: (keys: string[], values: string[], scope: number) => {
|
|
83
|
-
|
|
342
|
+
const storage = getBrowserStorage(scope);
|
|
343
|
+
if (!storage) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
keys.forEach((key, index) => {
|
|
348
|
+
const storageKey =
|
|
349
|
+
scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
|
|
350
|
+
storage.setItem(storageKey, values[index]);
|
|
351
|
+
});
|
|
352
|
+
if (scope === StorageScope.Disk || scope === StorageScope.Secure) {
|
|
353
|
+
const listeners = getScopedListeners(scope);
|
|
354
|
+
keys.forEach((key) => notifyKeyListeners(listeners, key));
|
|
355
|
+
}
|
|
84
356
|
},
|
|
85
357
|
getBatch: (keys: string[], scope: number) => {
|
|
86
|
-
|
|
358
|
+
const storage = getBrowserStorage(scope);
|
|
359
|
+
return keys.map((key) => {
|
|
360
|
+
const storageKey =
|
|
361
|
+
scope === StorageScope.Secure ? toSecureStorageKey(key) : key;
|
|
362
|
+
return storage?.getItem(storageKey) ?? undefined;
|
|
363
|
+
});
|
|
87
364
|
},
|
|
88
365
|
removeBatch: (keys: string[], scope: number) => {
|
|
89
|
-
keys.forEach((key) =>
|
|
366
|
+
keys.forEach((key) => {
|
|
367
|
+
WebStorage.remove(key, scope);
|
|
368
|
+
});
|
|
90
369
|
},
|
|
91
370
|
addOnChange: (
|
|
92
371
|
_scope: number,
|
|
93
|
-
_callback: (key: string, value: string | undefined) => void
|
|
372
|
+
_callback: (key: string, value: string | undefined) => void,
|
|
94
373
|
) => {
|
|
95
374
|
return () => {};
|
|
96
375
|
},
|
|
376
|
+
has: (key: string, scope: number) => {
|
|
377
|
+
const storage = getBrowserStorage(scope);
|
|
378
|
+
if (scope === StorageScope.Secure) {
|
|
379
|
+
return (
|
|
380
|
+
storage?.getItem(toSecureStorageKey(key)) !== null ||
|
|
381
|
+
storage?.getItem(toBiometricStorageKey(key)) !== null
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return storage?.getItem(key) !== null;
|
|
385
|
+
},
|
|
386
|
+
getAllKeys: (scope: number) => {
|
|
387
|
+
const storage = getBrowserStorage(scope);
|
|
388
|
+
if (!storage) return [];
|
|
389
|
+
const keys = new Set<string>();
|
|
390
|
+
for (let i = 0; i < storage.length; i++) {
|
|
391
|
+
const k = storage.key(i);
|
|
392
|
+
if (!k) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (scope === StorageScope.Secure) {
|
|
396
|
+
if (k.startsWith(SECURE_WEB_PREFIX)) {
|
|
397
|
+
keys.add(fromSecureStorageKey(k));
|
|
398
|
+
} else if (k.startsWith(BIOMETRIC_WEB_PREFIX)) {
|
|
399
|
+
keys.add(fromBiometricStorageKey(k));
|
|
400
|
+
}
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (
|
|
404
|
+
k.startsWith(SECURE_WEB_PREFIX) ||
|
|
405
|
+
k.startsWith(BIOMETRIC_WEB_PREFIX)
|
|
406
|
+
) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
keys.add(k);
|
|
410
|
+
}
|
|
411
|
+
return Array.from(keys);
|
|
412
|
+
},
|
|
413
|
+
size: (scope: number) => {
|
|
414
|
+
return WebStorage.getAllKeys(scope).length;
|
|
415
|
+
},
|
|
416
|
+
setSecureAccessControl: () => {},
|
|
417
|
+
setKeychainAccessGroup: () => {},
|
|
418
|
+
setSecureBiometric: (key: string, value: string) => {
|
|
419
|
+
if (
|
|
420
|
+
typeof __DEV__ !== "undefined" &&
|
|
421
|
+
__DEV__ &&
|
|
422
|
+
!hasWarnedAboutWebBiometricFallback
|
|
423
|
+
) {
|
|
424
|
+
hasWarnedAboutWebBiometricFallback = true;
|
|
425
|
+
console.warn(
|
|
426
|
+
"[NitroStorage] Biometric storage is not supported on web. Using localStorage.",
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
globalThis.localStorage?.setItem(toBiometricStorageKey(key), value);
|
|
430
|
+
notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
|
|
431
|
+
},
|
|
432
|
+
getSecureBiometric: (key: string) => {
|
|
433
|
+
return (
|
|
434
|
+
globalThis.localStorage?.getItem(toBiometricStorageKey(key)) ?? undefined
|
|
435
|
+
);
|
|
436
|
+
},
|
|
437
|
+
deleteSecureBiometric: (key: string) => {
|
|
438
|
+
globalThis.localStorage?.removeItem(toBiometricStorageKey(key));
|
|
439
|
+
notifyKeyListeners(getScopedListeners(StorageScope.Secure), key);
|
|
440
|
+
},
|
|
441
|
+
hasSecureBiometric: (key: string) => {
|
|
442
|
+
return (
|
|
443
|
+
globalThis.localStorage?.getItem(toBiometricStorageKey(key)) !== null
|
|
444
|
+
);
|
|
445
|
+
},
|
|
446
|
+
clearSecureBiometric: () => {
|
|
447
|
+
const storage = globalThis.localStorage;
|
|
448
|
+
if (!storage) return;
|
|
449
|
+
const keysToNotify: string[] = [];
|
|
450
|
+
const toRemove: string[] = [];
|
|
451
|
+
for (let i = 0; i < storage.length; i++) {
|
|
452
|
+
const k = storage.key(i);
|
|
453
|
+
if (k?.startsWith(BIOMETRIC_WEB_PREFIX)) {
|
|
454
|
+
toRemove.push(k);
|
|
455
|
+
keysToNotify.push(fromBiometricStorageKey(k));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
toRemove.forEach((k) => storage.removeItem(k));
|
|
459
|
+
const listeners = getScopedListeners(StorageScope.Secure);
|
|
460
|
+
keysToNotify.forEach((key) => notifyKeyListeners(listeners, key));
|
|
461
|
+
},
|
|
97
462
|
};
|
|
98
463
|
|
|
99
|
-
|
|
100
|
-
|
|
464
|
+
function getRawValue(key: string, scope: StorageScope): string | undefined {
|
|
465
|
+
assertValidScope(scope);
|
|
466
|
+
if (scope === StorageScope.Memory) {
|
|
467
|
+
const value = memoryStore.get(key);
|
|
468
|
+
return typeof value === "string" ? value : undefined;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (scope === StorageScope.Secure && hasPendingSecureWrite(key)) {
|
|
472
|
+
return readPendingSecureWrite(key);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return WebStorage.get(key, scope);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function setRawValue(key: string, value: string, scope: StorageScope): void {
|
|
479
|
+
assertValidScope(scope);
|
|
480
|
+
if (scope === StorageScope.Memory) {
|
|
481
|
+
memoryStore.set(key, value);
|
|
482
|
+
notifyKeyListeners(memoryListeners, key);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
101
485
|
|
|
102
|
-
|
|
103
|
-
|
|
486
|
+
if (scope === StorageScope.Secure) {
|
|
487
|
+
flushSecureWrites();
|
|
488
|
+
clearPendingSecureWrite(key);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
WebStorage.set(key, value, scope);
|
|
492
|
+
cacheRawValue(scope, key, value);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function removeRawValue(key: string, scope: StorageScope): void {
|
|
496
|
+
assertValidScope(scope);
|
|
497
|
+
if (scope === StorageScope.Memory) {
|
|
498
|
+
memoryStore.delete(key);
|
|
499
|
+
notifyKeyListeners(memoryListeners, key);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (scope === StorageScope.Secure) {
|
|
504
|
+
flushSecureWrites();
|
|
505
|
+
clearPendingSecureWrite(key);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
WebStorage.remove(key, scope);
|
|
509
|
+
cacheRawValue(scope, key, undefined);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function readMigrationVersion(scope: StorageScope): number {
|
|
513
|
+
const raw = getRawValue(MIGRATION_VERSION_KEY, scope);
|
|
514
|
+
if (raw === undefined) {
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const parsed = Number.parseInt(raw, 10);
|
|
519
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function writeMigrationVersion(scope: StorageScope, version: number): void {
|
|
523
|
+
setRawValue(MIGRATION_VERSION_KEY, String(version), scope);
|
|
104
524
|
}
|
|
105
525
|
|
|
106
526
|
export const storage = {
|
|
107
527
|
clear: (scope: StorageScope) => {
|
|
108
528
|
if (scope === StorageScope.Memory) {
|
|
109
529
|
memoryStore.clear();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
530
|
+
notifyAllListeners(memoryListeners);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (scope === StorageScope.Secure) {
|
|
535
|
+
flushSecureWrites();
|
|
536
|
+
pendingSecureWrites.clear();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
clearScopeRawCache(scope);
|
|
540
|
+
WebStorage.clear(scope);
|
|
541
|
+
if (scope === StorageScope.Secure) {
|
|
542
|
+
WebStorage.clearSecureBiometric();
|
|
113
543
|
}
|
|
114
544
|
},
|
|
115
545
|
clearAll: () => {
|
|
@@ -117,6 +547,66 @@ export const storage = {
|
|
|
117
547
|
storage.clear(StorageScope.Disk);
|
|
118
548
|
storage.clear(StorageScope.Secure);
|
|
119
549
|
},
|
|
550
|
+
clearNamespace: (namespace: string, scope: StorageScope) => {
|
|
551
|
+
assertValidScope(scope);
|
|
552
|
+
if (scope === StorageScope.Memory) {
|
|
553
|
+
for (const key of memoryStore.keys()) {
|
|
554
|
+
if (isNamespaced(key, namespace)) {
|
|
555
|
+
memoryStore.delete(key);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
notifyAllListeners(memoryListeners);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (scope === StorageScope.Secure) {
|
|
562
|
+
flushSecureWrites();
|
|
563
|
+
}
|
|
564
|
+
const keys = WebStorage.getAllKeys(scope);
|
|
565
|
+
const namespacedKeys = keys.filter((k) => isNamespaced(k, namespace));
|
|
566
|
+
if (namespacedKeys.length > 0) {
|
|
567
|
+
WebStorage.removeBatch(namespacedKeys, scope);
|
|
568
|
+
namespacedKeys.forEach((k) => cacheRawValue(scope, k, undefined));
|
|
569
|
+
if (scope === StorageScope.Secure) {
|
|
570
|
+
namespacedKeys.forEach((k) => clearPendingSecureWrite(k));
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
clearBiometric: () => {
|
|
575
|
+
WebStorage.clearSecureBiometric();
|
|
576
|
+
},
|
|
577
|
+
has: (key: string, scope: StorageScope): boolean => {
|
|
578
|
+
assertValidScope(scope);
|
|
579
|
+
if (scope === StorageScope.Memory) return memoryStore.has(key);
|
|
580
|
+
return WebStorage.has(key, scope);
|
|
581
|
+
},
|
|
582
|
+
getAllKeys: (scope: StorageScope): string[] => {
|
|
583
|
+
assertValidScope(scope);
|
|
584
|
+
if (scope === StorageScope.Memory) return Array.from(memoryStore.keys());
|
|
585
|
+
return WebStorage.getAllKeys(scope);
|
|
586
|
+
},
|
|
587
|
+
getAll: (scope: StorageScope): Record<string, string> => {
|
|
588
|
+
assertValidScope(scope);
|
|
589
|
+
const result: Record<string, string> = {};
|
|
590
|
+
if (scope === StorageScope.Memory) {
|
|
591
|
+
memoryStore.forEach((value, key) => {
|
|
592
|
+
if (typeof value === "string") result[key] = value;
|
|
593
|
+
});
|
|
594
|
+
return result;
|
|
595
|
+
}
|
|
596
|
+
const keys = WebStorage.getAllKeys(scope);
|
|
597
|
+
keys.forEach((key) => {
|
|
598
|
+
const val = WebStorage.get(key, scope);
|
|
599
|
+
if (val !== undefined) result[key] = val;
|
|
600
|
+
});
|
|
601
|
+
return result;
|
|
602
|
+
},
|
|
603
|
+
size: (scope: StorageScope): number => {
|
|
604
|
+
assertValidScope(scope);
|
|
605
|
+
if (scope === StorageScope.Memory) return memoryStore.size;
|
|
606
|
+
return WebStorage.size(scope);
|
|
607
|
+
},
|
|
608
|
+
setAccessControl: (_level: AccessControl) => {},
|
|
609
|
+
setKeychainAccessGroup: (_group: string) => {},
|
|
120
610
|
};
|
|
121
611
|
|
|
122
612
|
export interface StorageItemConfig<T> {
|
|
@@ -125,98 +615,297 @@ export interface StorageItemConfig<T> {
|
|
|
125
615
|
defaultValue?: T;
|
|
126
616
|
serialize?: (value: T) => string;
|
|
127
617
|
deserialize?: (value: string) => T;
|
|
618
|
+
validate?: Validator<T>;
|
|
619
|
+
onValidationError?: (invalidValue: unknown) => T;
|
|
620
|
+
expiration?: ExpirationConfig;
|
|
621
|
+
onExpired?: (key: string) => void;
|
|
622
|
+
readCache?: boolean;
|
|
623
|
+
coalesceSecureWrites?: boolean;
|
|
624
|
+
namespace?: string;
|
|
625
|
+
biometric?: boolean;
|
|
626
|
+
accessControl?: AccessControl;
|
|
128
627
|
}
|
|
129
628
|
|
|
130
629
|
export interface StorageItem<T> {
|
|
131
630
|
get: () => T;
|
|
132
631
|
set: (value: T | ((prev: T) => T)) => void;
|
|
133
632
|
delete: () => void;
|
|
633
|
+
has: () => boolean;
|
|
134
634
|
subscribe: (callback: () => void) => () => void;
|
|
135
635
|
serialize: (value: T) => string;
|
|
136
636
|
deserialize: (value: string) => T;
|
|
137
|
-
_triggerListeners: () => void;
|
|
138
637
|
scope: StorageScope;
|
|
139
638
|
key: string;
|
|
140
639
|
}
|
|
141
640
|
|
|
641
|
+
type StorageItemInternal<T> = StorageItem<T> & {
|
|
642
|
+
_triggerListeners: () => void;
|
|
643
|
+
_hasValidation: boolean;
|
|
644
|
+
_hasExpiration: boolean;
|
|
645
|
+
_readCacheEnabled: boolean;
|
|
646
|
+
_isBiometric: boolean;
|
|
647
|
+
_secureAccessControl?: AccessControl;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
function canUseRawBatchPath(item: RawBatchPathItem): boolean {
|
|
651
|
+
return (
|
|
652
|
+
item._hasExpiration === false &&
|
|
653
|
+
item._hasValidation === false &&
|
|
654
|
+
item._isBiometric !== true &&
|
|
655
|
+
item._secureAccessControl === undefined
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
142
659
|
function defaultSerialize<T>(value: T): string {
|
|
143
|
-
return
|
|
660
|
+
return serializeWithPrimitiveFastPath(value);
|
|
144
661
|
}
|
|
145
662
|
|
|
146
663
|
function defaultDeserialize<T>(value: string): T {
|
|
147
|
-
return
|
|
664
|
+
return deserializeWithPrimitiveFastPath(value);
|
|
148
665
|
}
|
|
149
666
|
|
|
150
667
|
export function createStorageItem<T = undefined>(
|
|
151
|
-
config: StorageItemConfig<T
|
|
668
|
+
config: StorageItemConfig<T>,
|
|
152
669
|
): StorageItem<T> {
|
|
670
|
+
const storageKey = prefixKey(config.namespace, config.key);
|
|
153
671
|
const serialize = config.serialize ?? defaultSerialize;
|
|
154
672
|
const deserialize = config.deserialize ?? defaultDeserialize;
|
|
155
673
|
const isMemory = config.scope === StorageScope.Memory;
|
|
674
|
+
const isBiometric =
|
|
675
|
+
config.biometric === true && config.scope === StorageScope.Secure;
|
|
676
|
+
const secureAccessControl = config.accessControl;
|
|
677
|
+
const validate = config.validate;
|
|
678
|
+
const onValidationError = config.onValidationError;
|
|
679
|
+
const expiration = config.expiration;
|
|
680
|
+
const onExpired = config.onExpired;
|
|
681
|
+
const expirationTtlMs = expiration?.ttlMs;
|
|
682
|
+
const memoryExpiration =
|
|
683
|
+
expiration && isMemory ? new Map<string, number>() : null;
|
|
684
|
+
const readCache = !isMemory && config.readCache === true;
|
|
685
|
+
const coalesceSecureWrites =
|
|
686
|
+
config.scope === StorageScope.Secure &&
|
|
687
|
+
config.coalesceSecureWrites === true &&
|
|
688
|
+
!isBiometric &&
|
|
689
|
+
secureAccessControl === undefined;
|
|
690
|
+
const nonMemoryScope: NonMemoryScope | null =
|
|
691
|
+
config.scope === StorageScope.Disk
|
|
692
|
+
? StorageScope.Disk
|
|
693
|
+
: config.scope === StorageScope.Secure
|
|
694
|
+
? StorageScope.Secure
|
|
695
|
+
: null;
|
|
696
|
+
|
|
697
|
+
if (expiration && expiration.ttlMs <= 0) {
|
|
698
|
+
throw new Error("expiration.ttlMs must be greater than 0.");
|
|
699
|
+
}
|
|
156
700
|
|
|
157
701
|
const listeners = new Set<() => void>();
|
|
158
702
|
let unsubscribe: (() => void) | null = null;
|
|
159
|
-
let lastRaw:
|
|
703
|
+
let lastRaw: unknown = undefined;
|
|
160
704
|
let lastValue: T | undefined;
|
|
705
|
+
let hasLastValue = false;
|
|
706
|
+
|
|
707
|
+
const invalidateParsedCache = () => {
|
|
708
|
+
lastRaw = undefined;
|
|
709
|
+
lastValue = undefined;
|
|
710
|
+
hasLastValue = false;
|
|
711
|
+
};
|
|
161
712
|
|
|
162
713
|
const ensureSubscription = () => {
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
714
|
+
if (unsubscribe) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const listener = () => {
|
|
719
|
+
invalidateParsedCache();
|
|
720
|
+
listeners.forEach((callback) => callback());
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
if (isMemory) {
|
|
724
|
+
unsubscribe = addKeyListener(memoryListeners, storageKey, listener);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
unsubscribe = addKeyListener(
|
|
729
|
+
getScopedListeners(nonMemoryScope!),
|
|
730
|
+
storageKey,
|
|
731
|
+
listener,
|
|
732
|
+
);
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const readStoredRaw = (): unknown => {
|
|
736
|
+
if (isMemory) {
|
|
737
|
+
if (memoryExpiration) {
|
|
738
|
+
const expiresAt = memoryExpiration.get(storageKey);
|
|
739
|
+
if (expiresAt !== undefined && expiresAt <= Date.now()) {
|
|
740
|
+
memoryExpiration.delete(storageKey);
|
|
741
|
+
memoryStore.delete(storageKey);
|
|
742
|
+
notifyKeyListeners(memoryListeners, storageKey);
|
|
743
|
+
onExpired?.(storageKey);
|
|
744
|
+
return undefined;
|
|
193
745
|
}
|
|
194
|
-
|
|
195
|
-
|
|
746
|
+
}
|
|
747
|
+
return memoryStore.get(storageKey) as T | undefined;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (
|
|
751
|
+
nonMemoryScope === StorageScope.Secure &&
|
|
752
|
+
!isBiometric &&
|
|
753
|
+
hasPendingSecureWrite(storageKey)
|
|
754
|
+
) {
|
|
755
|
+
return readPendingSecureWrite(storageKey);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (readCache) {
|
|
759
|
+
if (hasCachedRawValue(nonMemoryScope!, storageKey)) {
|
|
760
|
+
return readCachedRawValue(nonMemoryScope!, storageKey);
|
|
196
761
|
}
|
|
197
762
|
}
|
|
763
|
+
|
|
764
|
+
if (isBiometric) {
|
|
765
|
+
return WebStorage.getSecureBiometric(storageKey);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const raw = WebStorage.get(storageKey, config.scope);
|
|
769
|
+
cacheRawValue(nonMemoryScope!, storageKey, raw);
|
|
770
|
+
return raw;
|
|
198
771
|
};
|
|
199
772
|
|
|
200
|
-
const
|
|
201
|
-
|
|
773
|
+
const writeStoredRaw = (rawValue: string): void => {
|
|
774
|
+
if (isBiometric) {
|
|
775
|
+
WebStorage.setSecureBiometric(storageKey, rawValue);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
cacheRawValue(nonMemoryScope!, storageKey, rawValue);
|
|
780
|
+
|
|
781
|
+
if (coalesceSecureWrites) {
|
|
782
|
+
scheduleSecureWrite(storageKey, rawValue);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (nonMemoryScope === StorageScope.Secure) {
|
|
787
|
+
clearPendingSecureWrite(storageKey);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
WebStorage.set(storageKey, rawValue, config.scope);
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const removeStoredRaw = (): void => {
|
|
794
|
+
if (isBiometric) {
|
|
795
|
+
WebStorage.deleteSecureBiometric(storageKey);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
cacheRawValue(nonMemoryScope!, storageKey, undefined);
|
|
800
|
+
|
|
801
|
+
if (coalesceSecureWrites) {
|
|
802
|
+
scheduleSecureWrite(storageKey, undefined);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (nonMemoryScope === StorageScope.Secure) {
|
|
807
|
+
clearPendingSecureWrite(storageKey);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
WebStorage.remove(storageKey, config.scope);
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
const writeValueWithoutValidation = (value: T): void => {
|
|
202
814
|
if (isMemory) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
815
|
+
if (memoryExpiration) {
|
|
816
|
+
memoryExpiration.set(storageKey, Date.now() + (expirationTtlMs ?? 0));
|
|
817
|
+
}
|
|
818
|
+
memoryStore.set(storageKey, value);
|
|
819
|
+
notifyKeyListeners(memoryListeners, storageKey);
|
|
820
|
+
return;
|
|
206
821
|
}
|
|
207
822
|
|
|
208
|
-
|
|
209
|
-
|
|
823
|
+
const serialized = serialize(value);
|
|
824
|
+
if (expiration) {
|
|
825
|
+
const envelope: StoredEnvelope = {
|
|
826
|
+
__nitroStorageEnvelope: true,
|
|
827
|
+
expiresAt: Date.now() + expiration.ttlMs,
|
|
828
|
+
payload: serialized,
|
|
829
|
+
};
|
|
830
|
+
writeStoredRaw(JSON.stringify(envelope));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
writeStoredRaw(serialized);
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const resolveInvalidValue = (invalidValue: unknown): T => {
|
|
838
|
+
if (onValidationError) {
|
|
839
|
+
return onValidationError(invalidValue);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return config.defaultValue as T;
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const ensureValidatedValue = (
|
|
846
|
+
candidate: unknown,
|
|
847
|
+
hadStoredValue: boolean,
|
|
848
|
+
): T => {
|
|
849
|
+
if (!validate || validate(candidate)) {
|
|
850
|
+
return candidate as T;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const resolved = resolveInvalidValue(candidate);
|
|
854
|
+
if (validate && !validate(resolved)) {
|
|
855
|
+
return config.defaultValue as T;
|
|
856
|
+
}
|
|
857
|
+
if (hadStoredValue) {
|
|
858
|
+
writeValueWithoutValidation(resolved);
|
|
859
|
+
}
|
|
860
|
+
return resolved;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const get = (): T => {
|
|
864
|
+
const raw = readStoredRaw();
|
|
865
|
+
|
|
866
|
+
const canUseCachedValue = !expiration && !memoryExpiration;
|
|
867
|
+
if (canUseCachedValue && raw === lastRaw && hasLastValue) {
|
|
868
|
+
return lastValue as T;
|
|
210
869
|
}
|
|
211
870
|
|
|
212
871
|
lastRaw = raw;
|
|
213
872
|
|
|
214
873
|
if (raw === undefined) {
|
|
215
|
-
lastValue = config.defaultValue
|
|
216
|
-
|
|
217
|
-
lastValue
|
|
874
|
+
lastValue = ensureValidatedValue(config.defaultValue, false);
|
|
875
|
+
hasLastValue = true;
|
|
876
|
+
return lastValue;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (isMemory) {
|
|
880
|
+
lastValue = ensureValidatedValue(raw, true);
|
|
881
|
+
hasLastValue = true;
|
|
882
|
+
return lastValue;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
let deserializableRaw = raw as string;
|
|
886
|
+
|
|
887
|
+
if (expiration) {
|
|
888
|
+
try {
|
|
889
|
+
const parsed = JSON.parse(raw as string) as unknown;
|
|
890
|
+
if (isStoredEnvelope(parsed)) {
|
|
891
|
+
if (parsed.expiresAt <= Date.now()) {
|
|
892
|
+
removeStoredRaw();
|
|
893
|
+
invalidateParsedCache();
|
|
894
|
+
onExpired?.(storageKey);
|
|
895
|
+
lastValue = ensureValidatedValue(config.defaultValue, false);
|
|
896
|
+
hasLastValue = true;
|
|
897
|
+
return lastValue;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
deserializableRaw = parsed.payload;
|
|
901
|
+
}
|
|
902
|
+
} catch {
|
|
903
|
+
// Keep backward compatibility with legacy raw values.
|
|
904
|
+
}
|
|
218
905
|
}
|
|
219
906
|
|
|
907
|
+
lastValue = ensureValidatedValue(deserialize(deserializableRaw), true);
|
|
908
|
+
hasLastValue = true;
|
|
220
909
|
return lastValue;
|
|
221
910
|
};
|
|
222
911
|
|
|
@@ -227,25 +916,36 @@ export function createStorageItem<T = undefined>(
|
|
|
227
916
|
? (valueOrFn as (prev: T) => T)(currentValue)
|
|
228
917
|
: valueOrFn;
|
|
229
918
|
|
|
230
|
-
|
|
919
|
+
invalidateParsedCache();
|
|
231
920
|
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
WebStorage.set(config.key, serialize(newValue), config.scope);
|
|
921
|
+
if (validate && !validate(newValue)) {
|
|
922
|
+
throw new Error(
|
|
923
|
+
`Validation failed for key "${storageKey}" in scope "${StorageScope[config.scope]}".`,
|
|
924
|
+
);
|
|
237
925
|
}
|
|
926
|
+
|
|
927
|
+
writeValueWithoutValidation(newValue);
|
|
238
928
|
};
|
|
239
929
|
|
|
240
930
|
const deleteItem = (): void => {
|
|
241
|
-
|
|
931
|
+
invalidateParsedCache();
|
|
242
932
|
|
|
243
933
|
if (isMemory) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
934
|
+
if (memoryExpiration) {
|
|
935
|
+
memoryExpiration.delete(storageKey);
|
|
936
|
+
}
|
|
937
|
+
memoryStore.delete(storageKey);
|
|
938
|
+
notifyKeyListeners(memoryListeners, storageKey);
|
|
939
|
+
return;
|
|
248
940
|
}
|
|
941
|
+
|
|
942
|
+
removeStoredRaw();
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
const hasItem = (): boolean => {
|
|
946
|
+
if (isMemory) return memoryStore.has(storageKey);
|
|
947
|
+
if (isBiometric) return WebStorage.hasSecureBiometric(storageKey);
|
|
948
|
+
return WebStorage.has(storageKey, config.scope);
|
|
249
949
|
};
|
|
250
950
|
|
|
251
951
|
const subscribe = (callback: () => void): (() => void) => {
|
|
@@ -260,47 +960,139 @@ export function createStorageItem<T = undefined>(
|
|
|
260
960
|
};
|
|
261
961
|
};
|
|
262
962
|
|
|
263
|
-
|
|
963
|
+
const storageItem: StorageItemInternal<T> = {
|
|
264
964
|
get,
|
|
265
965
|
set,
|
|
266
966
|
delete: deleteItem,
|
|
967
|
+
has: hasItem,
|
|
267
968
|
subscribe,
|
|
268
969
|
serialize,
|
|
269
970
|
deserialize,
|
|
270
971
|
_triggerListeners: () => {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
listeners.forEach((l) => l());
|
|
972
|
+
invalidateParsedCache();
|
|
973
|
+
listeners.forEach((listener) => listener());
|
|
274
974
|
},
|
|
975
|
+
_hasValidation: validate !== undefined,
|
|
976
|
+
_hasExpiration: expiration !== undefined,
|
|
977
|
+
_readCacheEnabled: readCache,
|
|
978
|
+
_isBiometric: isBiometric,
|
|
979
|
+
_secureAccessControl: secureAccessControl,
|
|
275
980
|
scope: config.scope,
|
|
276
|
-
key:
|
|
981
|
+
key: storageKey,
|
|
277
982
|
};
|
|
983
|
+
|
|
984
|
+
return storageItem as StorageItem<T>;
|
|
278
985
|
}
|
|
279
986
|
|
|
280
987
|
export function useStorage<T>(
|
|
281
|
-
item: StorageItem<T
|
|
988
|
+
item: StorageItem<T>,
|
|
282
989
|
): [T, (value: T | ((prev: T) => T)) => void] {
|
|
283
990
|
const value = useSyncExternalStore(item.subscribe, item.get, item.get);
|
|
284
991
|
return [value, item.set];
|
|
285
992
|
}
|
|
286
993
|
|
|
994
|
+
export function useStorageSelector<T, TSelected>(
|
|
995
|
+
item: StorageItem<T>,
|
|
996
|
+
selector: (value: T) => TSelected,
|
|
997
|
+
isEqual: (prev: TSelected, next: TSelected) => boolean = Object.is,
|
|
998
|
+
): [TSelected, (value: T | ((prev: T) => T)) => void] {
|
|
999
|
+
const selectedRef = useRef<
|
|
1000
|
+
{ hasValue: false } | { hasValue: true; value: TSelected }
|
|
1001
|
+
>({
|
|
1002
|
+
hasValue: false,
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
const getSelectedSnapshot = () => {
|
|
1006
|
+
const nextSelected = selector(item.get());
|
|
1007
|
+
const current = selectedRef.current;
|
|
1008
|
+
if (current.hasValue && isEqual(current.value, nextSelected)) {
|
|
1009
|
+
return current.value;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
selectedRef.current = { hasValue: true, value: nextSelected };
|
|
1013
|
+
return nextSelected;
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const selectedValue = useSyncExternalStore(
|
|
1017
|
+
item.subscribe,
|
|
1018
|
+
getSelectedSnapshot,
|
|
1019
|
+
getSelectedSnapshot,
|
|
1020
|
+
);
|
|
1021
|
+
return [selectedValue, item.set];
|
|
1022
|
+
}
|
|
1023
|
+
|
|
287
1024
|
export function useSetStorage<T>(item: StorageItem<T>) {
|
|
288
1025
|
return item.set;
|
|
289
1026
|
}
|
|
290
1027
|
|
|
1028
|
+
type BatchReadItem<T> = Pick<
|
|
1029
|
+
StorageItem<T>,
|
|
1030
|
+
"key" | "scope" | "get" | "deserialize"
|
|
1031
|
+
> & {
|
|
1032
|
+
_hasValidation?: boolean;
|
|
1033
|
+
_hasExpiration?: boolean;
|
|
1034
|
+
_readCacheEnabled?: boolean;
|
|
1035
|
+
_isBiometric?: boolean;
|
|
1036
|
+
_secureAccessControl?: AccessControl;
|
|
1037
|
+
};
|
|
1038
|
+
type BatchRemoveItem = Pick<StorageItem<unknown>, "key" | "scope" | "delete">;
|
|
1039
|
+
|
|
1040
|
+
export type StorageBatchSetItem<T> = {
|
|
1041
|
+
item: StorageItem<T>;
|
|
1042
|
+
value: T;
|
|
1043
|
+
};
|
|
1044
|
+
|
|
291
1045
|
export function getBatch(
|
|
292
|
-
items:
|
|
293
|
-
scope: StorageScope
|
|
294
|
-
):
|
|
1046
|
+
items: readonly BatchReadItem<unknown>[],
|
|
1047
|
+
scope: StorageScope,
|
|
1048
|
+
): unknown[] {
|
|
1049
|
+
assertBatchScope(items, scope);
|
|
1050
|
+
|
|
295
1051
|
if (scope === StorageScope.Memory) {
|
|
296
1052
|
return items.map((item) => item.get());
|
|
297
1053
|
}
|
|
298
1054
|
|
|
299
|
-
const
|
|
300
|
-
|
|
1055
|
+
const useRawBatchPath = items.every((item) => canUseRawBatchPath(item));
|
|
1056
|
+
if (!useRawBatchPath) {
|
|
1057
|
+
return items.map((item) => item.get());
|
|
1058
|
+
}
|
|
1059
|
+
const useBatchCache = items.every((item) => item._readCacheEnabled === true);
|
|
1060
|
+
|
|
1061
|
+
const rawValues = new Array<string | undefined>(items.length);
|
|
1062
|
+
const keysToFetch: string[] = [];
|
|
1063
|
+
const keyIndexes: number[] = [];
|
|
301
1064
|
|
|
302
|
-
|
|
303
|
-
|
|
1065
|
+
items.forEach((item, index) => {
|
|
1066
|
+
if (scope === StorageScope.Secure) {
|
|
1067
|
+
if (hasPendingSecureWrite(item.key)) {
|
|
1068
|
+
rawValues[index] = readPendingSecureWrite(item.key);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (useBatchCache) {
|
|
1074
|
+
if (hasCachedRawValue(scope, item.key)) {
|
|
1075
|
+
rawValues[index] = readCachedRawValue(scope, item.key);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
keysToFetch.push(item.key);
|
|
1081
|
+
keyIndexes.push(index);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
if (keysToFetch.length > 0) {
|
|
1085
|
+
const fetchedValues = WebStorage.getBatch(keysToFetch, scope);
|
|
1086
|
+
fetchedValues.forEach((value, index) => {
|
|
1087
|
+
const key = keysToFetch[index];
|
|
1088
|
+
const targetIndex = keyIndexes[index];
|
|
1089
|
+
rawValues[targetIndex] = value;
|
|
1090
|
+
cacheRawValue(scope, key, value);
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return items.map((item, index) => {
|
|
1095
|
+
const raw = rawValues[index];
|
|
304
1096
|
if (raw === undefined) {
|
|
305
1097
|
return item.get();
|
|
306
1098
|
}
|
|
@@ -308,34 +1100,187 @@ export function getBatch(
|
|
|
308
1100
|
});
|
|
309
1101
|
}
|
|
310
1102
|
|
|
311
|
-
export function setBatch(
|
|
312
|
-
items:
|
|
313
|
-
scope: StorageScope
|
|
1103
|
+
export function setBatch<T>(
|
|
1104
|
+
items: readonly StorageBatchSetItem<T>[],
|
|
1105
|
+
scope: StorageScope,
|
|
314
1106
|
): void {
|
|
1107
|
+
assertBatchScope(
|
|
1108
|
+
items.map((batchEntry) => batchEntry.item),
|
|
1109
|
+
scope,
|
|
1110
|
+
);
|
|
1111
|
+
|
|
315
1112
|
if (scope === StorageScope.Memory) {
|
|
316
1113
|
items.forEach(({ item, value }) => item.set(value));
|
|
317
1114
|
return;
|
|
318
1115
|
}
|
|
319
1116
|
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
1117
|
+
const useRawBatchPath = items.every(({ item }) =>
|
|
1118
|
+
canUseRawBatchPath(asInternal(item)),
|
|
1119
|
+
);
|
|
1120
|
+
if (!useRawBatchPath) {
|
|
1121
|
+
items.forEach(({ item, value }) => item.set(value));
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
323
1124
|
|
|
324
|
-
items.
|
|
325
|
-
|
|
326
|
-
|
|
1125
|
+
const keys = items.map((entry) => entry.item.key);
|
|
1126
|
+
const values = items.map((entry) => entry.item.serialize(entry.value));
|
|
1127
|
+
if (scope === StorageScope.Secure) {
|
|
1128
|
+
flushSecureWrites();
|
|
1129
|
+
}
|
|
1130
|
+
WebStorage.setBatch(keys, values, scope);
|
|
1131
|
+
keys.forEach((key, index) => cacheRawValue(scope, key, values[index]));
|
|
327
1132
|
}
|
|
328
1133
|
|
|
329
1134
|
export function removeBatch(
|
|
330
|
-
items:
|
|
331
|
-
scope: StorageScope
|
|
1135
|
+
items: readonly BatchRemoveItem[],
|
|
1136
|
+
scope: StorageScope,
|
|
332
1137
|
): void {
|
|
1138
|
+
assertBatchScope(items, scope);
|
|
1139
|
+
|
|
333
1140
|
if (scope === StorageScope.Memory) {
|
|
334
1141
|
items.forEach((item) => item.delete());
|
|
335
1142
|
return;
|
|
336
1143
|
}
|
|
337
1144
|
|
|
338
1145
|
const keys = items.map((item) => item.key);
|
|
1146
|
+
if (scope === StorageScope.Secure) {
|
|
1147
|
+
flushSecureWrites();
|
|
1148
|
+
}
|
|
339
1149
|
WebStorage.removeBatch(keys, scope);
|
|
340
|
-
|
|
1150
|
+
keys.forEach((key) => cacheRawValue(scope, key, undefined));
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
export function registerMigration(version: number, migration: Migration): void {
|
|
1154
|
+
if (!Number.isInteger(version) || version <= 0) {
|
|
1155
|
+
throw new Error("Migration version must be a positive integer.");
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (registeredMigrations.has(version)) {
|
|
1159
|
+
throw new Error(`Migration version ${version} is already registered.`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
registeredMigrations.set(version, migration);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
export function migrateToLatest(
|
|
1166
|
+
scope: StorageScope = StorageScope.Disk,
|
|
1167
|
+
): number {
|
|
1168
|
+
assertValidScope(scope);
|
|
1169
|
+
const currentVersion = readMigrationVersion(scope);
|
|
1170
|
+
const versions = Array.from(registeredMigrations.keys())
|
|
1171
|
+
.filter((version) => version > currentVersion)
|
|
1172
|
+
.sort((a, b) => a - b);
|
|
1173
|
+
|
|
1174
|
+
let appliedVersion = currentVersion;
|
|
1175
|
+
const context: MigrationContext = {
|
|
1176
|
+
scope,
|
|
1177
|
+
getRaw: (key) => getRawValue(key, scope),
|
|
1178
|
+
setRaw: (key, value) => setRawValue(key, value, scope),
|
|
1179
|
+
removeRaw: (key) => removeRawValue(key, scope),
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
versions.forEach((version) => {
|
|
1183
|
+
const migration = registeredMigrations.get(version);
|
|
1184
|
+
if (!migration) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
migration(context);
|
|
1188
|
+
writeMigrationVersion(scope, version);
|
|
1189
|
+
appliedVersion = version;
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
return appliedVersion;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
export function runTransaction<T>(
|
|
1196
|
+
scope: StorageScope,
|
|
1197
|
+
transaction: (context: TransactionContext) => T,
|
|
1198
|
+
): T {
|
|
1199
|
+
assertValidScope(scope);
|
|
1200
|
+
if (scope === StorageScope.Secure) {
|
|
1201
|
+
flushSecureWrites();
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const rollback = new Map<string, string | undefined>();
|
|
1205
|
+
|
|
1206
|
+
const rememberRollback = (key: string) => {
|
|
1207
|
+
if (rollback.has(key)) {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
rollback.set(key, getRawValue(key, scope));
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const tx: TransactionContext = {
|
|
1214
|
+
scope,
|
|
1215
|
+
getRaw: (key) => getRawValue(key, scope),
|
|
1216
|
+
setRaw: (key, value) => {
|
|
1217
|
+
rememberRollback(key);
|
|
1218
|
+
setRawValue(key, value, scope);
|
|
1219
|
+
},
|
|
1220
|
+
removeRaw: (key) => {
|
|
1221
|
+
rememberRollback(key);
|
|
1222
|
+
removeRawValue(key, scope);
|
|
1223
|
+
},
|
|
1224
|
+
getItem: (item) => {
|
|
1225
|
+
assertBatchScope([item], scope);
|
|
1226
|
+
return item.get();
|
|
1227
|
+
},
|
|
1228
|
+
setItem: (item, value) => {
|
|
1229
|
+
assertBatchScope([item], scope);
|
|
1230
|
+
rememberRollback(item.key);
|
|
1231
|
+
item.set(value);
|
|
1232
|
+
},
|
|
1233
|
+
removeItem: (item) => {
|
|
1234
|
+
assertBatchScope([item], scope);
|
|
1235
|
+
rememberRollback(item.key);
|
|
1236
|
+
item.delete();
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
try {
|
|
1241
|
+
return transaction(tx);
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
Array.from(rollback.entries())
|
|
1244
|
+
.reverse()
|
|
1245
|
+
.forEach(([key, previousValue]) => {
|
|
1246
|
+
if (previousValue === undefined) {
|
|
1247
|
+
removeRawValue(key, scope);
|
|
1248
|
+
} else {
|
|
1249
|
+
setRawValue(key, previousValue, scope);
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
throw error;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
export type SecureAuthStorageConfig<K extends string = string> = Record<
|
|
1257
|
+
K,
|
|
1258
|
+
{
|
|
1259
|
+
ttlMs?: number;
|
|
1260
|
+
biometric?: boolean;
|
|
1261
|
+
accessControl?: AccessControl;
|
|
1262
|
+
}
|
|
1263
|
+
>;
|
|
1264
|
+
|
|
1265
|
+
export function createSecureAuthStorage<K extends string>(
|
|
1266
|
+
config: SecureAuthStorageConfig<K>,
|
|
1267
|
+
options?: { namespace?: string },
|
|
1268
|
+
): Record<K, StorageItem<string>> {
|
|
1269
|
+
const ns = options?.namespace ?? "auth";
|
|
1270
|
+
const result = {} as Record<K, StorageItem<string>>;
|
|
1271
|
+
|
|
1272
|
+
for (const key of Object.keys(config) as K[]) {
|
|
1273
|
+
const itemConfig = config[key];
|
|
1274
|
+
result[key] = createStorageItem<string>({
|
|
1275
|
+
key,
|
|
1276
|
+
scope: StorageScope.Secure,
|
|
1277
|
+
defaultValue: "",
|
|
1278
|
+
namespace: ns,
|
|
1279
|
+
biometric: itemConfig.biometric,
|
|
1280
|
+
accessControl: itemConfig.accessControl,
|
|
1281
|
+
expiration: itemConfig.ttlMs ? { ttlMs: itemConfig.ttlMs } : undefined,
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
return result;
|
|
341
1286
|
}
|