@esportsplus/web-storage 0.5.0 → 0.5.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/build/drivers/localstorage.js +7 -1
- package/build/drivers/sessionstorage.js +7 -1
- package/build/index.d.ts +1 -1
- package/build/index.js +82 -44
- package/build/lz.js +12 -8
- package/build/types.d.ts +1 -1
- package/package.json +5 -5
- package/src/drivers/localstorage.ts +9 -1
- package/src/drivers/sessionstorage.ts +9 -1
- package/src/index.ts +110 -52
- package/src/lz.ts +18 -10
- package/src/types.ts +1 -1
- package/tests/index.ts +155 -10
- package/tests/lz.ts +47 -0
- package/storage/test-audit-web-storage.md +0 -74
|
@@ -52,7 +52,13 @@ class LocalStorageDriver {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
async count() {
|
|
55
|
-
|
|
55
|
+
let count = 0;
|
|
56
|
+
for (let i = 0, n = localStorage.length; i < n; i++) {
|
|
57
|
+
if (localStorage.key(i)?.startsWith(this.prefix)) {
|
|
58
|
+
count++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return count;
|
|
56
62
|
}
|
|
57
63
|
async delete(keys) {
|
|
58
64
|
for (let i = 0, n = keys.length; i < n; i++) {
|
|
@@ -52,7 +52,13 @@ class SessionStorageDriver {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
async count() {
|
|
55
|
-
|
|
55
|
+
let count = 0;
|
|
56
|
+
for (let i = 0, n = sessionStorage.length; i < n; i++) {
|
|
57
|
+
if (sessionStorage.key(i)?.startsWith(this.prefix)) {
|
|
58
|
+
count++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return count;
|
|
56
62
|
}
|
|
57
63
|
async delete(keys) {
|
|
58
64
|
for (let i = 0, n = keys.length; i < n; i++) {
|
package/build/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Filter, GlobalCallback, KeyCallback, Options, SetOptions } from './types.js';
|
|
2
2
|
declare class Local<T> {
|
|
3
|
+
private cipher;
|
|
3
4
|
private driver;
|
|
4
5
|
private globals;
|
|
5
6
|
private listeners;
|
|
6
7
|
private ready;
|
|
7
|
-
private secret;
|
|
8
8
|
private version;
|
|
9
9
|
constructor(options: Options, secret?: string);
|
|
10
10
|
all(): Promise<T>;
|
package/build/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { encryption } from '@esportsplus/utilities';
|
|
2
2
|
import { DriverType } from './constants.js';
|
|
3
3
|
import { IndexedDBDriver } from './drivers/indexeddb.js';
|
|
4
4
|
import { LocalStorageDriver } from './drivers/localstorage.js';
|
|
@@ -11,14 +11,13 @@ function isEnvelope(value) {
|
|
|
11
11
|
&& '__e' in value
|
|
12
12
|
&& '__v' in value;
|
|
13
13
|
}
|
|
14
|
-
async function deserialize(value,
|
|
14
|
+
async function deserialize(value, cipher) {
|
|
15
15
|
if (value === undefined || value === null) {
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
|
-
if (
|
|
18
|
+
if (cipher && typeof value === 'string') {
|
|
19
19
|
try {
|
|
20
|
-
value = await decrypt(value
|
|
21
|
-
value = JSON.parse(value);
|
|
20
|
+
value = await cipher.decrypt(value);
|
|
22
21
|
}
|
|
23
22
|
catch {
|
|
24
23
|
return undefined;
|
|
@@ -26,12 +25,12 @@ async function deserialize(value, secret) {
|
|
|
26
25
|
}
|
|
27
26
|
return value;
|
|
28
27
|
}
|
|
29
|
-
async function serialize(value,
|
|
28
|
+
async function serialize(value, cipher) {
|
|
30
29
|
if (value === undefined || value === null) {
|
|
31
30
|
return value;
|
|
32
31
|
}
|
|
33
|
-
if (
|
|
34
|
-
return encrypt(
|
|
32
|
+
if (cipher) {
|
|
33
|
+
return cipher.encrypt(value);
|
|
35
34
|
}
|
|
36
35
|
return value;
|
|
37
36
|
}
|
|
@@ -70,11 +69,11 @@ async function migrate(driver, migrations, version) {
|
|
|
70
69
|
}
|
|
71
70
|
}
|
|
72
71
|
let transformed = await migrations[keys[i]]({ all: () => Promise.resolve(data) });
|
|
73
|
-
await driver.clear();
|
|
74
72
|
let entries = [];
|
|
75
73
|
for (let key in transformed) {
|
|
76
74
|
entries.push([key, transformed[key]]);
|
|
77
75
|
}
|
|
76
|
+
await driver.clear();
|
|
78
77
|
if (entries.length > 0) {
|
|
79
78
|
await driver.replace(entries);
|
|
80
79
|
}
|
|
@@ -82,16 +81,16 @@ async function migrate(driver, migrations, version) {
|
|
|
82
81
|
await driver.set(VERSION_KEY, version);
|
|
83
82
|
}
|
|
84
83
|
class Local {
|
|
84
|
+
cipher;
|
|
85
85
|
driver;
|
|
86
86
|
globals;
|
|
87
87
|
listeners;
|
|
88
88
|
ready;
|
|
89
|
-
secret;
|
|
90
89
|
version;
|
|
91
90
|
constructor(options, secret) {
|
|
91
|
+
this.cipher = null;
|
|
92
92
|
this.globals = new Set();
|
|
93
93
|
this.listeners = new Map();
|
|
94
|
-
this.secret = secret || null;
|
|
95
94
|
let { migrations, name, version = 1 } = options;
|
|
96
95
|
this.version = version;
|
|
97
96
|
if (options.driver === DriverType.LocalStorage) {
|
|
@@ -106,11 +105,14 @@ class Local {
|
|
|
106
105
|
else {
|
|
107
106
|
this.driver = new IndexedDBDriver(name, version);
|
|
108
107
|
}
|
|
108
|
+
let init = secret
|
|
109
|
+
? encryption(secret).then((c) => { this.cipher = c; })
|
|
110
|
+
: Promise.resolve();
|
|
109
111
|
if (migrations) {
|
|
110
|
-
this.ready = migrate(this.driver, migrations, version);
|
|
112
|
+
this.ready = init.then(() => migrate(this.driver, migrations, version));
|
|
111
113
|
}
|
|
112
114
|
else {
|
|
113
|
-
this.ready =
|
|
115
|
+
this.ready = init;
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
async all() {
|
|
@@ -120,7 +122,7 @@ class Local {
|
|
|
120
122
|
if (key === VERSION_KEY) {
|
|
121
123
|
continue;
|
|
122
124
|
}
|
|
123
|
-
let deserialized = await deserialize(raw[key], this.
|
|
125
|
+
let deserialized = await deserialize(raw[key], this.cipher), unwrapped = unwrap(deserialized);
|
|
124
126
|
if (deserialized === undefined) {
|
|
125
127
|
continue;
|
|
126
128
|
}
|
|
@@ -131,7 +133,7 @@ class Local {
|
|
|
131
133
|
result[key] = unwrapped.value;
|
|
132
134
|
}
|
|
133
135
|
if (expired.length > 0) {
|
|
134
|
-
this.driver.delete(expired);
|
|
136
|
+
await this.driver.delete(expired);
|
|
135
137
|
}
|
|
136
138
|
return result;
|
|
137
139
|
}
|
|
@@ -151,7 +153,7 @@ class Local {
|
|
|
151
153
|
if (key === VERSION_KEY) {
|
|
152
154
|
return;
|
|
153
155
|
}
|
|
154
|
-
let deserialized = await deserialize(raw, this.
|
|
156
|
+
let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
|
|
155
157
|
if (deserialized !== undefined && unwrapped.expired) {
|
|
156
158
|
expired.push(key);
|
|
157
159
|
oldValues.set(key, unwrapped.value);
|
|
@@ -166,15 +168,32 @@ class Local {
|
|
|
166
168
|
}
|
|
167
169
|
async count() {
|
|
168
170
|
await this.ready;
|
|
169
|
-
let
|
|
170
|
-
|
|
171
|
+
let expired = [], total = 0;
|
|
172
|
+
await this.driver.map(async (raw, key) => {
|
|
173
|
+
if (key === VERSION_KEY) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
|
|
177
|
+
if (deserialized === undefined) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (unwrapped.expired) {
|
|
181
|
+
expired.push(key);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
total++;
|
|
185
|
+
});
|
|
186
|
+
if (expired.length > 0) {
|
|
187
|
+
await this.driver.delete(expired);
|
|
188
|
+
}
|
|
189
|
+
return total;
|
|
171
190
|
}
|
|
172
191
|
async delete(...keys) {
|
|
173
192
|
await this.ready;
|
|
174
|
-
let oldValues = new Map();
|
|
175
|
-
for (let
|
|
176
|
-
let
|
|
177
|
-
oldValues.set(
|
|
193
|
+
let oldValues = new Map(), raw = await this.driver.only(keys);
|
|
194
|
+
for (let [key, value] of raw) {
|
|
195
|
+
let deserialized = await deserialize(value, this.cipher), unwrapped = unwrap(deserialized);
|
|
196
|
+
oldValues.set(key, deserialized === undefined ? undefined : unwrapped.value);
|
|
178
197
|
}
|
|
179
198
|
await this.driver.delete(keys);
|
|
180
199
|
for (let i = 0, n = keys.length; i < n; i++) {
|
|
@@ -188,7 +207,7 @@ class Local {
|
|
|
188
207
|
if (stopped || key === VERSION_KEY) {
|
|
189
208
|
return;
|
|
190
209
|
}
|
|
191
|
-
let deserialized = await deserialize(raw, this.
|
|
210
|
+
let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
|
|
192
211
|
if (deserialized === undefined) {
|
|
193
212
|
return;
|
|
194
213
|
}
|
|
@@ -201,18 +220,18 @@ class Local {
|
|
|
201
220
|
}
|
|
202
221
|
});
|
|
203
222
|
if (expired.length > 0) {
|
|
204
|
-
this.driver.delete(expired);
|
|
223
|
+
await this.driver.delete(expired);
|
|
205
224
|
}
|
|
206
225
|
return result;
|
|
207
226
|
}
|
|
208
227
|
async get(key, factory) {
|
|
209
228
|
await this.ready;
|
|
210
|
-
let deserialized = await deserialize(await this.driver.get(key), this.
|
|
229
|
+
let deserialized = await deserialize(await this.driver.get(key), this.cipher), missing = false, unwrapped = unwrap(deserialized);
|
|
211
230
|
if (deserialized === undefined) {
|
|
212
231
|
missing = true;
|
|
213
232
|
}
|
|
214
233
|
else if (unwrapped.expired) {
|
|
215
|
-
this.driver.delete([key]);
|
|
234
|
+
await this.driver.delete([key]);
|
|
216
235
|
missing = true;
|
|
217
236
|
}
|
|
218
237
|
if (missing) {
|
|
@@ -227,8 +246,25 @@ class Local {
|
|
|
227
246
|
}
|
|
228
247
|
async keys() {
|
|
229
248
|
await this.ready;
|
|
230
|
-
let
|
|
231
|
-
|
|
249
|
+
let expired = [], result = [];
|
|
250
|
+
await this.driver.map(async (raw, key) => {
|
|
251
|
+
if (key === VERSION_KEY) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
|
|
255
|
+
if (deserialized === undefined) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (unwrapped.expired) {
|
|
259
|
+
expired.push(key);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
result.push(key);
|
|
263
|
+
});
|
|
264
|
+
if (expired.length > 0) {
|
|
265
|
+
await this.driver.delete(expired);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
232
268
|
}
|
|
233
269
|
async length() {
|
|
234
270
|
await this.ready;
|
|
@@ -241,7 +277,7 @@ class Local {
|
|
|
241
277
|
if (key === VERSION_KEY) {
|
|
242
278
|
return;
|
|
243
279
|
}
|
|
244
|
-
let deserialized = await deserialize(raw, this.
|
|
280
|
+
let deserialized = await deserialize(raw, this.cipher), unwrapped = unwrap(deserialized);
|
|
245
281
|
if (deserialized === undefined) {
|
|
246
282
|
return;
|
|
247
283
|
}
|
|
@@ -252,14 +288,14 @@ class Local {
|
|
|
252
288
|
await fn(unwrapped.value, key, j++);
|
|
253
289
|
});
|
|
254
290
|
if (expired.length > 0) {
|
|
255
|
-
this.driver.delete(expired);
|
|
291
|
+
await this.driver.delete(expired);
|
|
256
292
|
}
|
|
257
293
|
}
|
|
258
294
|
async only(...keys) {
|
|
259
295
|
await this.ready;
|
|
260
296
|
let expired = [], raw = await this.driver.only(keys), result = {};
|
|
261
297
|
for (let [key, value] of raw) {
|
|
262
|
-
let deserialized = await deserialize(value, this.
|
|
298
|
+
let deserialized = await deserialize(value, this.cipher), unwrapped = unwrap(deserialized);
|
|
263
299
|
if (deserialized === undefined) {
|
|
264
300
|
continue;
|
|
265
301
|
}
|
|
@@ -270,36 +306,38 @@ class Local {
|
|
|
270
306
|
result[key] = unwrapped.value;
|
|
271
307
|
}
|
|
272
308
|
if (expired.length > 0) {
|
|
273
|
-
this.driver.delete(expired);
|
|
309
|
+
await this.driver.delete(expired);
|
|
274
310
|
}
|
|
275
311
|
return result;
|
|
276
312
|
}
|
|
277
313
|
async persist(key) {
|
|
278
314
|
await this.ready;
|
|
279
|
-
let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.
|
|
315
|
+
let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.cipher);
|
|
280
316
|
if (deserialized === undefined) {
|
|
281
317
|
return false;
|
|
282
318
|
}
|
|
283
319
|
let unwrapped = unwrap(deserialized);
|
|
284
320
|
if (unwrapped.expired) {
|
|
285
|
-
this.driver.delete([key]);
|
|
321
|
+
await this.driver.delete([key]);
|
|
286
322
|
return false;
|
|
287
323
|
}
|
|
288
324
|
if (!unwrapped.hasTTL) {
|
|
289
325
|
return true;
|
|
290
326
|
}
|
|
291
|
-
return this.driver.set(key, await serialize(unwrapped.value, this.
|
|
327
|
+
return this.driver.set(key, await serialize(unwrapped.value, this.cipher));
|
|
292
328
|
}
|
|
293
329
|
async replace(values) {
|
|
294
330
|
await this.ready;
|
|
295
|
-
let entries = [], failed = [], oldValues = new Map();
|
|
296
|
-
for (let key
|
|
297
|
-
let
|
|
331
|
+
let entries = [], failed = [], fetchKeys = Object.keys(values), oldValues = new Map(), raw = await this.driver.only(fetchKeys);
|
|
332
|
+
for (let key of fetchKeys) {
|
|
333
|
+
let value = raw.get(key), deserialized = value !== undefined
|
|
334
|
+
? await deserialize(value, this.cipher)
|
|
335
|
+
: undefined, unwrapped = unwrap(deserialized);
|
|
298
336
|
oldValues.set(key, deserialized === undefined ? undefined : unwrapped.value);
|
|
299
337
|
try {
|
|
300
338
|
entries.push([
|
|
301
339
|
key,
|
|
302
|
-
await serialize(values[key], this.
|
|
340
|
+
await serialize(values[key], this.cipher)
|
|
303
341
|
]);
|
|
304
342
|
}
|
|
305
343
|
catch {
|
|
@@ -318,16 +356,16 @@ class Local {
|
|
|
318
356
|
async set(key, value, options) {
|
|
319
357
|
await this.ready;
|
|
320
358
|
try {
|
|
321
|
-
let oldRaw = await this.driver.get(key), oldDeserialized = await deserialize(oldRaw, this.
|
|
359
|
+
let oldRaw = await this.driver.get(key), oldDeserialized = await deserialize(oldRaw, this.cipher), oldUnwrapped = unwrap(oldDeserialized), oldValue = oldDeserialized === undefined ? undefined : oldUnwrapped.value, stored;
|
|
322
360
|
if (options?.ttl != null && options.ttl > 0) {
|
|
323
361
|
let envelope = {
|
|
324
362
|
__e: Date.now() + options.ttl,
|
|
325
363
|
__v: value
|
|
326
364
|
};
|
|
327
|
-
stored = await serialize(envelope, this.
|
|
365
|
+
stored = await serialize(envelope, this.cipher);
|
|
328
366
|
}
|
|
329
367
|
else {
|
|
330
|
-
stored = await serialize(value, this.
|
|
368
|
+
stored = await serialize(value, this.cipher);
|
|
331
369
|
}
|
|
332
370
|
let result = await this.driver.set(key, stored);
|
|
333
371
|
notify(this.globals, this.listeners, key, value, oldValue);
|
|
@@ -353,7 +391,7 @@ class Local {
|
|
|
353
391
|
}
|
|
354
392
|
async ttl(key) {
|
|
355
393
|
await this.ready;
|
|
356
|
-
let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.
|
|
394
|
+
let raw = await this.driver.get(key), deserialized = await deserialize(raw, this.cipher);
|
|
357
395
|
if (deserialized === undefined) {
|
|
358
396
|
return -1;
|
|
359
397
|
}
|
|
@@ -362,7 +400,7 @@ class Local {
|
|
|
362
400
|
}
|
|
363
401
|
let remaining = deserialized.__e - Date.now();
|
|
364
402
|
if (remaining <= 0) {
|
|
365
|
-
this.driver.delete([key]);
|
|
403
|
+
await this.driver.delete([key]);
|
|
366
404
|
return -1;
|
|
367
405
|
}
|
|
368
406
|
return remaining;
|
package/build/lz.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
let MAX_DECOMPRESSED_SIZE = 10_485_760;
|
|
1
2
|
function emitLiteral(ctx, ch) {
|
|
2
3
|
let code = ch.charCodeAt(0);
|
|
3
4
|
if (code < 256) {
|
|
@@ -13,6 +14,9 @@ function readBits(ctx, n) {
|
|
|
13
14
|
let result = 0;
|
|
14
15
|
for (let i = 0; i < n; i++) {
|
|
15
16
|
if (ctx.bitPos > 15) {
|
|
17
|
+
if (ctx.pos >= ctx.compressed.length) {
|
|
18
|
+
throw new Error('LZ: unexpected end of compressed data');
|
|
19
|
+
}
|
|
16
20
|
ctx.currentValue = ctx.compressed.charCodeAt(ctx.pos++) - 1;
|
|
17
21
|
ctx.bitPos = 0;
|
|
18
22
|
}
|
|
@@ -74,11 +78,7 @@ const compress = (input) => {
|
|
|
74
78
|
ctx.output.push(((ctx.buffer << (16 - ctx.bitsInBuffer)) & 0xFFFF) + 1);
|
|
75
79
|
}
|
|
76
80
|
ctx.output.push((ctx.bitsInBuffer === 0 ? 16 : ctx.bitsInBuffer) + 1);
|
|
77
|
-
|
|
78
|
-
for (let i = 0, n = ctx.output.length; i < n; i++) {
|
|
79
|
-
chars.push(String.fromCharCode(ctx.output[i]));
|
|
80
|
-
}
|
|
81
|
-
return chars.join('');
|
|
81
|
+
return String.fromCharCode(...ctx.output);
|
|
82
82
|
};
|
|
83
83
|
const decompress = (compressed) => {
|
|
84
84
|
if (!compressed) {
|
|
@@ -96,7 +96,7 @@ const decompress = (compressed) => {
|
|
|
96
96
|
else {
|
|
97
97
|
return '';
|
|
98
98
|
}
|
|
99
|
-
let
|
|
99
|
+
let parts = [entry], totalLength = entry.length, w = entry;
|
|
100
100
|
while (true) {
|
|
101
101
|
let slotIdx = dictionary.length;
|
|
102
102
|
dictionary.push('');
|
|
@@ -126,9 +126,13 @@ const decompress = (compressed) => {
|
|
|
126
126
|
throw new Error('LZ: invalid decompression code');
|
|
127
127
|
}
|
|
128
128
|
dictionary[slotIdx] = w + entry[0];
|
|
129
|
-
|
|
129
|
+
parts.push(entry);
|
|
130
|
+
totalLength += entry.length;
|
|
131
|
+
if (totalLength > MAX_DECOMPRESSED_SIZE) {
|
|
132
|
+
throw new Error('LZ: decompressed output exceeds size limit');
|
|
133
|
+
}
|
|
130
134
|
w = entry;
|
|
131
135
|
}
|
|
132
|
-
return
|
|
136
|
+
return parts.join('');
|
|
133
137
|
};
|
|
134
138
|
export { compress, decompress };
|
package/build/types.d.ts
CHANGED
|
@@ -36,4 +36,4 @@ type TTLEnvelope<V> = {
|
|
|
36
36
|
};
|
|
37
37
|
type GlobalCallback<T> = (key: keyof T, newValue: T[keyof T] | undefined, oldValue: T[keyof T] | undefined) => void;
|
|
38
38
|
type KeyCallback<T, K extends keyof T = keyof T> = (newValue: T[K] | undefined, oldValue: T[K] | undefined) => void;
|
|
39
|
-
export type { Driver, Filter, GlobalCallback, KeyCallback,
|
|
39
|
+
export type { Driver, Filter, GlobalCallback, KeyCallback, MigrationFn, Options, SetOptions, TTLEnvelope };
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"author": "ICJR",
|
|
3
3
|
"dependencies": {
|
|
4
|
-
"@esportsplus/utilities": "^0.
|
|
4
|
+
"@esportsplus/utilities": "^0.28.0"
|
|
5
5
|
},
|
|
6
6
|
"description": "Web storage utility",
|
|
7
7
|
"devDependencies": {
|
|
8
|
-
"@esportsplus/typescript": "^0.
|
|
8
|
+
"@esportsplus/typescript": "^0.29.1",
|
|
9
9
|
"fake-indexeddb": "^6.2.5",
|
|
10
|
-
"happy-dom": "^20.8.
|
|
11
|
-
"vitest": "^4.1.
|
|
10
|
+
"happy-dom": "^20.8.9",
|
|
11
|
+
"vitest": "^4.1.4"
|
|
12
12
|
},
|
|
13
13
|
"main": "build/index.js",
|
|
14
14
|
"name": "@esportsplus/web-storage",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"type": "module",
|
|
21
21
|
"types": "build/index.d.ts",
|
|
22
|
-
"version": "0.5.
|
|
22
|
+
"version": "0.5.1",
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsc && tsc-alias",
|
|
25
25
|
"test": "vitest run",
|
|
@@ -78,7 +78,15 @@ class LocalStorageDriver<T> implements Driver<T> {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
async count(): Promise<number> {
|
|
81
|
-
|
|
81
|
+
let count = 0;
|
|
82
|
+
|
|
83
|
+
for (let i = 0, n = localStorage.length; i < n; i++) {
|
|
84
|
+
if (localStorage.key(i)?.startsWith(this.prefix)) {
|
|
85
|
+
count++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return count;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
async delete(keys: (keyof T)[]): Promise<void> {
|
|
@@ -78,7 +78,15 @@ class SessionStorageDriver<T> implements Driver<T> {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
async count(): Promise<number> {
|
|
81
|
-
|
|
81
|
+
let count = 0;
|
|
82
|
+
|
|
83
|
+
for (let i = 0, n = sessionStorage.length; i < n; i++) {
|
|
84
|
+
if (sessionStorage.key(i)?.startsWith(this.prefix)) {
|
|
85
|
+
count++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return count;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
async delete(keys: (keyof T)[]): Promise<void> {
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { Driver, Filter, GlobalCallback, KeyCallback, MigrationFn, Options, SetOptions, TTLEnvelope } from './types';
|
|
1
|
+
import { encryption } from '@esportsplus/utilities';
|
|
3
2
|
import { DriverType } from './constants';
|
|
4
3
|
import { IndexedDBDriver } from '~/drivers/indexeddb';
|
|
5
4
|
import { LocalStorageDriver } from '~/drivers/localstorage';
|
|
6
5
|
import { MemoryDriver } from '~/drivers/memory';
|
|
7
6
|
import { SessionStorageDriver } from '~/drivers/sessionstorage';
|
|
8
7
|
|
|
8
|
+
import type { Cipher } from '@esportsplus/utilities';
|
|
9
|
+
import type { Driver, Filter, GlobalCallback, KeyCallback, MigrationFn, Options, SetOptions, TTLEnvelope } from './types';
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
const VERSION_KEY = '__version__';
|
|
11
13
|
|
|
@@ -17,15 +19,14 @@ function isEnvelope<V>(value: unknown): value is TTLEnvelope<V> {
|
|
|
17
19
|
&& '__v' in (value as Record<string, unknown>);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
async function deserialize<V>(value: unknown,
|
|
22
|
+
async function deserialize<V>(value: unknown, cipher: Cipher | null): Promise<V | undefined> {
|
|
21
23
|
if (value === undefined || value === null) {
|
|
22
24
|
return undefined;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
if (
|
|
27
|
+
if (cipher && typeof value === 'string') {
|
|
26
28
|
try {
|
|
27
|
-
value = await decrypt(value
|
|
28
|
-
value = JSON.parse(value as string);
|
|
29
|
+
value = await cipher.decrypt(value);
|
|
29
30
|
}
|
|
30
31
|
catch {
|
|
31
32
|
return undefined;
|
|
@@ -35,13 +36,13 @@ async function deserialize<V>(value: unknown, secret: string | null): Promise<V
|
|
|
35
36
|
return value as V;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
async function serialize<V>(value: V,
|
|
39
|
+
async function serialize<V>(value: V, cipher: Cipher | null): Promise<string | V> {
|
|
39
40
|
if (value === undefined || value === null) {
|
|
40
41
|
return value;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
if (
|
|
44
|
-
return encrypt(
|
|
44
|
+
if (cipher) {
|
|
45
|
+
return cipher.encrypt(value);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
return value;
|
|
@@ -101,14 +102,14 @@ async function migrate<T>(driver: Driver<T>, migrations: Record<number, Migratio
|
|
|
101
102
|
|
|
102
103
|
let transformed = await migrations[keys[i]]({ all: () => Promise.resolve(data) });
|
|
103
104
|
|
|
104
|
-
await driver.clear();
|
|
105
|
-
|
|
106
105
|
let entries: [keyof T, T[keyof T]][] = [];
|
|
107
106
|
|
|
108
107
|
for (let key in transformed) {
|
|
109
108
|
entries.push([key as keyof T, transformed[key] as T[keyof T]]);
|
|
110
109
|
}
|
|
111
110
|
|
|
111
|
+
await driver.clear();
|
|
112
|
+
|
|
112
113
|
if (entries.length > 0) {
|
|
113
114
|
await driver.replace(entries);
|
|
114
115
|
}
|
|
@@ -120,6 +121,8 @@ async function migrate<T>(driver: Driver<T>, migrations: Record<number, Migratio
|
|
|
120
121
|
|
|
121
122
|
class Local<T> {
|
|
122
123
|
|
|
124
|
+
private cipher: Cipher | null;
|
|
125
|
+
|
|
123
126
|
private driver: Driver<T>;
|
|
124
127
|
|
|
125
128
|
private globals: Set<GlobalCallback<T>>;
|
|
@@ -128,15 +131,13 @@ class Local<T> {
|
|
|
128
131
|
|
|
129
132
|
private ready: Promise<void>;
|
|
130
133
|
|
|
131
|
-
private secret: string | null;
|
|
132
|
-
|
|
133
134
|
private version: number;
|
|
134
135
|
|
|
135
136
|
|
|
136
137
|
constructor(options: Options, secret?: string) {
|
|
138
|
+
this.cipher = null;
|
|
137
139
|
this.globals = new Set();
|
|
138
140
|
this.listeners = new Map();
|
|
139
|
-
this.secret = secret || null;
|
|
140
141
|
|
|
141
142
|
let { migrations, name, version = 1 } = options;
|
|
142
143
|
|
|
@@ -155,11 +156,15 @@ class Local<T> {
|
|
|
155
156
|
this.driver = new IndexedDBDriver<T>(name, version);
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
let init = secret
|
|
160
|
+
? encryption(secret).then((c) => { this.cipher = c; })
|
|
161
|
+
: Promise.resolve();
|
|
162
|
+
|
|
158
163
|
if (migrations) {
|
|
159
|
-
this.ready = migrate(this.driver, migrations, version);
|
|
164
|
+
this.ready = init.then(() => migrate(this.driver, migrations, version));
|
|
160
165
|
}
|
|
161
166
|
else {
|
|
162
|
-
this.ready =
|
|
167
|
+
this.ready = init;
|
|
163
168
|
}
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -176,7 +181,7 @@ class Local<T> {
|
|
|
176
181
|
continue;
|
|
177
182
|
}
|
|
178
183
|
|
|
179
|
-
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw[key], this.
|
|
184
|
+
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw[key], this.cipher),
|
|
180
185
|
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
181
186
|
|
|
182
187
|
if (deserialized === undefined) {
|
|
@@ -192,7 +197,7 @@ class Local<T> {
|
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
if (expired.length > 0) {
|
|
195
|
-
this.driver.delete(expired);
|
|
200
|
+
await this.driver.delete(expired);
|
|
196
201
|
}
|
|
197
202
|
|
|
198
203
|
return result;
|
|
@@ -223,7 +228,7 @@ class Local<T> {
|
|
|
223
228
|
return;
|
|
224
229
|
}
|
|
225
230
|
|
|
226
|
-
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.
|
|
231
|
+
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
|
|
227
232
|
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
228
233
|
|
|
229
234
|
if (deserialized !== undefined && unwrapped.expired) {
|
|
@@ -244,23 +249,47 @@ class Local<T> {
|
|
|
244
249
|
async count(): Promise<number> {
|
|
245
250
|
await this.ready;
|
|
246
251
|
|
|
247
|
-
let
|
|
248
|
-
|
|
252
|
+
let expired: (keyof T)[] = [],
|
|
253
|
+
total = 0;
|
|
254
|
+
|
|
255
|
+
await this.driver.map(async (raw, key) => {
|
|
256
|
+
if (key as string === VERSION_KEY) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
249
259
|
|
|
250
|
-
|
|
260
|
+
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
|
|
261
|
+
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
262
|
+
|
|
263
|
+
if (deserialized === undefined) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (unwrapped.expired) {
|
|
268
|
+
expired.push(key);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
total++;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (expired.length > 0) {
|
|
276
|
+
await this.driver.delete(expired);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return total;
|
|
251
280
|
}
|
|
252
281
|
|
|
253
282
|
async delete(...keys: (keyof T)[]): Promise<void> {
|
|
254
283
|
await this.ready;
|
|
255
284
|
|
|
256
|
-
let oldValues = new Map<keyof T, T[keyof T] | undefined>()
|
|
285
|
+
let oldValues = new Map<keyof T, T[keyof T] | undefined>(),
|
|
286
|
+
raw = await this.driver.only(keys);
|
|
257
287
|
|
|
258
|
-
for (let
|
|
259
|
-
let
|
|
260
|
-
deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.secret),
|
|
288
|
+
for (let [key, value] of raw) {
|
|
289
|
+
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(value, this.cipher),
|
|
261
290
|
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
262
291
|
|
|
263
|
-
oldValues.set(
|
|
292
|
+
oldValues.set(key, deserialized === undefined ? undefined : unwrapped.value);
|
|
264
293
|
}
|
|
265
294
|
|
|
266
295
|
await this.driver.delete(keys);
|
|
@@ -284,7 +313,7 @@ class Local<T> {
|
|
|
284
313
|
return;
|
|
285
314
|
}
|
|
286
315
|
|
|
287
|
-
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.
|
|
316
|
+
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
|
|
288
317
|
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
289
318
|
|
|
290
319
|
if (deserialized === undefined) {
|
|
@@ -302,7 +331,7 @@ class Local<T> {
|
|
|
302
331
|
});
|
|
303
332
|
|
|
304
333
|
if (expired.length > 0) {
|
|
305
|
-
this.driver.delete(expired);
|
|
334
|
+
await this.driver.delete(expired);
|
|
306
335
|
}
|
|
307
336
|
|
|
308
337
|
return result;
|
|
@@ -315,7 +344,7 @@ class Local<T> {
|
|
|
315
344
|
|
|
316
345
|
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(
|
|
317
346
|
await this.driver.get(key),
|
|
318
|
-
this.
|
|
347
|
+
this.cipher
|
|
319
348
|
),
|
|
320
349
|
missing = false,
|
|
321
350
|
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
@@ -324,7 +353,7 @@ class Local<T> {
|
|
|
324
353
|
missing = true;
|
|
325
354
|
}
|
|
326
355
|
else if (unwrapped.expired) {
|
|
327
|
-
this.driver.delete([key]);
|
|
356
|
+
await this.driver.delete([key]);
|
|
328
357
|
missing = true;
|
|
329
358
|
}
|
|
330
359
|
|
|
@@ -346,9 +375,34 @@ class Local<T> {
|
|
|
346
375
|
async keys(): Promise<(keyof T)[]> {
|
|
347
376
|
await this.ready;
|
|
348
377
|
|
|
349
|
-
let
|
|
378
|
+
let expired: (keyof T)[] = [],
|
|
379
|
+
result: (keyof T)[] = [];
|
|
380
|
+
|
|
381
|
+
await this.driver.map(async (raw, key) => {
|
|
382
|
+
if (key as string === VERSION_KEY) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
|
|
387
|
+
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
388
|
+
|
|
389
|
+
if (deserialized === undefined) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (unwrapped.expired) {
|
|
394
|
+
expired.push(key);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
result.push(key);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
if (expired.length > 0) {
|
|
402
|
+
await this.driver.delete(expired);
|
|
403
|
+
}
|
|
350
404
|
|
|
351
|
-
return
|
|
405
|
+
return result;
|
|
352
406
|
}
|
|
353
407
|
|
|
354
408
|
async length(): Promise<number> {
|
|
@@ -368,7 +422,7 @@ class Local<T> {
|
|
|
368
422
|
return;
|
|
369
423
|
}
|
|
370
424
|
|
|
371
|
-
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.
|
|
425
|
+
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher),
|
|
372
426
|
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
373
427
|
|
|
374
428
|
if (deserialized === undefined) {
|
|
@@ -384,7 +438,7 @@ class Local<T> {
|
|
|
384
438
|
});
|
|
385
439
|
|
|
386
440
|
if (expired.length > 0) {
|
|
387
|
-
this.driver.delete(expired);
|
|
441
|
+
await this.driver.delete(expired);
|
|
388
442
|
}
|
|
389
443
|
}
|
|
390
444
|
|
|
@@ -396,7 +450,7 @@ class Local<T> {
|
|
|
396
450
|
result = {} as T;
|
|
397
451
|
|
|
398
452
|
for (let [key, value] of raw) {
|
|
399
|
-
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(value, this.
|
|
453
|
+
let deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(value, this.cipher),
|
|
400
454
|
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
401
455
|
|
|
402
456
|
if (deserialized === undefined) {
|
|
@@ -412,7 +466,7 @@ class Local<T> {
|
|
|
412
466
|
}
|
|
413
467
|
|
|
414
468
|
if (expired.length > 0) {
|
|
415
|
-
this.driver.delete(expired);
|
|
469
|
+
await this.driver.delete(expired);
|
|
416
470
|
}
|
|
417
471
|
|
|
418
472
|
return result;
|
|
@@ -422,7 +476,7 @@ class Local<T> {
|
|
|
422
476
|
await this.ready;
|
|
423
477
|
|
|
424
478
|
let raw = await this.driver.get(key),
|
|
425
|
-
deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.
|
|
479
|
+
deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher);
|
|
426
480
|
|
|
427
481
|
if (deserialized === undefined) {
|
|
428
482
|
return false;
|
|
@@ -431,7 +485,7 @@ class Local<T> {
|
|
|
431
485
|
let unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
432
486
|
|
|
433
487
|
if (unwrapped.expired) {
|
|
434
|
-
this.driver.delete([key]);
|
|
488
|
+
await this.driver.delete([key]);
|
|
435
489
|
return false;
|
|
436
490
|
}
|
|
437
491
|
|
|
@@ -441,7 +495,7 @@ class Local<T> {
|
|
|
441
495
|
|
|
442
496
|
return this.driver.set(
|
|
443
497
|
key,
|
|
444
|
-
await serialize(unwrapped.value, this.
|
|
498
|
+
await serialize(unwrapped.value, this.cipher) as T[keyof T]
|
|
445
499
|
);
|
|
446
500
|
}
|
|
447
501
|
|
|
@@ -450,11 +504,15 @@ class Local<T> {
|
|
|
450
504
|
|
|
451
505
|
let entries: [keyof T, unknown][] = [],
|
|
452
506
|
failed: string[] = [],
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
507
|
+
fetchKeys = Object.keys(values) as (keyof T)[],
|
|
508
|
+
oldValues = new Map<keyof T, T[keyof T] | undefined>(),
|
|
509
|
+
raw = await this.driver.only(fetchKeys);
|
|
510
|
+
|
|
511
|
+
for (let key of fetchKeys) {
|
|
512
|
+
let value = raw.get(key),
|
|
513
|
+
deserialized = value !== undefined
|
|
514
|
+
? await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(value, this.cipher)
|
|
515
|
+
: undefined,
|
|
458
516
|
unwrapped = unwrap<T[keyof T]>(deserialized);
|
|
459
517
|
|
|
460
518
|
oldValues.set(key, deserialized === undefined ? undefined : unwrapped.value);
|
|
@@ -462,11 +520,11 @@ class Local<T> {
|
|
|
462
520
|
try {
|
|
463
521
|
entries.push([
|
|
464
522
|
key,
|
|
465
|
-
await serialize(values[key], this.
|
|
523
|
+
await serialize(values[key], this.cipher)
|
|
466
524
|
]);
|
|
467
525
|
}
|
|
468
526
|
catch {
|
|
469
|
-
failed.push(key);
|
|
527
|
+
failed.push(key as string);
|
|
470
528
|
}
|
|
471
529
|
}
|
|
472
530
|
|
|
@@ -488,7 +546,7 @@ class Local<T> {
|
|
|
488
546
|
|
|
489
547
|
try {
|
|
490
548
|
let oldRaw = await this.driver.get(key),
|
|
491
|
-
oldDeserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(oldRaw, this.
|
|
549
|
+
oldDeserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(oldRaw, this.cipher),
|
|
492
550
|
oldUnwrapped = unwrap<T[keyof T]>(oldDeserialized),
|
|
493
551
|
oldValue = oldDeserialized === undefined ? undefined : oldUnwrapped.value,
|
|
494
552
|
stored: T[keyof T] | string;
|
|
@@ -499,10 +557,10 @@ class Local<T> {
|
|
|
499
557
|
__v: value
|
|
500
558
|
};
|
|
501
559
|
|
|
502
|
-
stored = await serialize(envelope, this.
|
|
560
|
+
stored = await serialize(envelope, this.cipher) as T[keyof T];
|
|
503
561
|
}
|
|
504
562
|
else {
|
|
505
|
-
stored = await serialize(value, this.
|
|
563
|
+
stored = await serialize(value, this.cipher) as T[keyof T];
|
|
506
564
|
}
|
|
507
565
|
|
|
508
566
|
let result = await this.driver.set(key, stored as T[keyof T]);
|
|
@@ -545,7 +603,7 @@ class Local<T> {
|
|
|
545
603
|
await this.ready;
|
|
546
604
|
|
|
547
605
|
let raw = await this.driver.get(key),
|
|
548
|
-
deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.
|
|
606
|
+
deserialized = await deserialize<T[keyof T] | TTLEnvelope<T[keyof T]>>(raw, this.cipher);
|
|
549
607
|
|
|
550
608
|
if (deserialized === undefined) {
|
|
551
609
|
return -1;
|
|
@@ -558,7 +616,7 @@ class Local<T> {
|
|
|
558
616
|
let remaining = deserialized.__e - Date.now();
|
|
559
617
|
|
|
560
618
|
if (remaining <= 0) {
|
|
561
|
-
this.driver.delete([key]);
|
|
619
|
+
await this.driver.delete([key]);
|
|
562
620
|
return -1;
|
|
563
621
|
}
|
|
564
622
|
|
package/src/lz.ts
CHANGED
|
@@ -2,6 +2,9 @@ type CompressCtx = { bitsInBuffer: number; buffer: number; numBits: number; outp
|
|
|
2
2
|
type DecompressCtx = { bitPos: number; compressed: string; currentValue: number; pos: number };
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
let MAX_DECOMPRESSED_SIZE = 10_485_760;
|
|
6
|
+
|
|
7
|
+
|
|
5
8
|
function emitLiteral(ctx: CompressCtx, ch: string) {
|
|
6
9
|
let code = ch.charCodeAt(0);
|
|
7
10
|
|
|
@@ -20,6 +23,10 @@ function readBits(ctx: DecompressCtx, n: number): number {
|
|
|
20
23
|
|
|
21
24
|
for (let i = 0; i < n; i++) {
|
|
22
25
|
if (ctx.bitPos > 15) {
|
|
26
|
+
if (ctx.pos >= ctx.compressed.length) {
|
|
27
|
+
throw new Error('LZ: unexpected end of compressed data');
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
ctx.currentValue = ctx.compressed.charCodeAt(ctx.pos++) - 1;
|
|
24
31
|
ctx.bitPos = 0;
|
|
25
32
|
}
|
|
@@ -107,13 +114,7 @@ const compress = (input: string): string => {
|
|
|
107
114
|
|
|
108
115
|
ctx.output.push((ctx.bitsInBuffer === 0 ? 16 : ctx.bitsInBuffer) + 1);
|
|
109
116
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
for (let i = 0, n = ctx.output.length; i < n; i++) {
|
|
113
|
-
chars.push(String.fromCharCode(ctx.output[i]));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return chars.join('');
|
|
117
|
+
return String.fromCharCode(...ctx.output);
|
|
117
118
|
};
|
|
118
119
|
|
|
119
120
|
const decompress = (compressed: string): string => {
|
|
@@ -141,7 +142,8 @@ const decompress = (compressed: string): string => {
|
|
|
141
142
|
return '';
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
let
|
|
145
|
+
let parts: string[] = [entry],
|
|
146
|
+
totalLength = entry.length,
|
|
145
147
|
w = entry;
|
|
146
148
|
|
|
147
149
|
while (true) {
|
|
@@ -181,11 +183,17 @@ const decompress = (compressed: string): string => {
|
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
dictionary[slotIdx] = w + entry[0];
|
|
184
|
-
|
|
186
|
+
parts.push(entry);
|
|
187
|
+
totalLength += entry.length;
|
|
188
|
+
|
|
189
|
+
if (totalLength > MAX_DECOMPRESSED_SIZE) {
|
|
190
|
+
throw new Error('LZ: decompressed output exceeds size limit');
|
|
191
|
+
}
|
|
192
|
+
|
|
185
193
|
w = entry;
|
|
186
194
|
}
|
|
187
195
|
|
|
188
|
-
return
|
|
196
|
+
return parts.join('');
|
|
189
197
|
};
|
|
190
198
|
|
|
191
199
|
|
package/src/types.ts
CHANGED
|
@@ -44,4 +44,4 @@ type GlobalCallback<T> = (key: keyof T, newValue: T[keyof T] | undefined, oldVal
|
|
|
44
44
|
type KeyCallback<T, K extends keyof T = keyof T> = (newValue: T[K] | undefined, oldValue: T[K] | undefined) => void;
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
export type { Driver, Filter, GlobalCallback, KeyCallback,
|
|
47
|
+
export type { Driver, Filter, GlobalCallback, KeyCallback, MigrationFn, Options, SetOptions, TTLEnvelope };
|
package/tests/index.ts
CHANGED
|
@@ -2,16 +2,22 @@ import 'fake-indexeddb/auto';
|
|
|
2
2
|
|
|
3
3
|
import { vi } from 'vitest';
|
|
4
4
|
|
|
5
|
+
let mockDecrypt = vi.fn(async (content: string) => {
|
|
6
|
+
return JSON.parse(atob(content));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
let mockEncrypt = vi.fn(async (content: unknown) => {
|
|
10
|
+
return btoa(JSON.stringify(content));
|
|
11
|
+
});
|
|
12
|
+
|
|
5
13
|
vi.mock('@esportsplus/utilities', () => ({
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return btoa(content as string);
|
|
11
|
-
})
|
|
14
|
+
encryption: vi.fn(async (_password: string) => ({
|
|
15
|
+
decrypt: (...args: unknown[]) => mockDecrypt(...args as [string]),
|
|
16
|
+
encrypt: (...args: unknown[]) => mockEncrypt(...args as [unknown])
|
|
17
|
+
}))
|
|
12
18
|
}));
|
|
13
19
|
|
|
14
|
-
import {
|
|
20
|
+
import { encryption } from '@esportsplus/utilities';
|
|
15
21
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
22
|
|
|
17
23
|
import createLocal, { DriverType } from '~/index';
|
|
@@ -282,13 +288,13 @@ describe('Local (LocalStorage driver)', () => {
|
|
|
282
288
|
it('get — returns undefined when decrypt fails', async () => {
|
|
283
289
|
await encrypted.set('name', 'alice');
|
|
284
290
|
|
|
285
|
-
|
|
291
|
+
mockDecrypt.mockRejectedValueOnce(new Error('decrypt failed'));
|
|
286
292
|
|
|
287
293
|
expect(await encrypted.get('name')).toBeUndefined();
|
|
288
294
|
});
|
|
289
295
|
|
|
290
296
|
it('replace — returns failed keys when encrypt throws', async () => {
|
|
291
|
-
|
|
297
|
+
mockEncrypt.mockRejectedValueOnce(new Error('encrypt failed'));
|
|
292
298
|
|
|
293
299
|
let failed = await encrypted.replace({ age: 25, name: 'bob' });
|
|
294
300
|
|
|
@@ -298,7 +304,7 @@ describe('Local (LocalStorage driver)', () => {
|
|
|
298
304
|
});
|
|
299
305
|
|
|
300
306
|
it('set — returns false when encrypt throws', async () => {
|
|
301
|
-
|
|
307
|
+
mockEncrypt.mockRejectedValueOnce(new Error('encrypt failed'));
|
|
302
308
|
|
|
303
309
|
expect(await encrypted.set('name', 'alice')).toBe(false);
|
|
304
310
|
});
|
|
@@ -1869,3 +1875,142 @@ describe('Compression + Encryption (LocalStorage)', () => {
|
|
|
1869
1875
|
expect(await store.get('payload')).toBe(largeValue);
|
|
1870
1876
|
});
|
|
1871
1877
|
});
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
describe('get(key, factory) — error handling', () => {
|
|
1881
|
+
|
|
1882
|
+
it('rejects when factory throws sync error', async () => {
|
|
1883
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-err-1', version: 1 });
|
|
1884
|
+
|
|
1885
|
+
await expect(store.get('name', () => { throw new Error('factory boom'); }))
|
|
1886
|
+
.rejects.toThrow('factory boom');
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
it('rejects when factory returns rejected promise', async () => {
|
|
1890
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'factory-err-2', version: 1 });
|
|
1891
|
+
|
|
1892
|
+
await expect(store.get('name', async () => { throw new Error('async boom'); }))
|
|
1893
|
+
.rejects.toThrow('async boom');
|
|
1894
|
+
});
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
describe('set() TTL boundary values', () => {
|
|
1899
|
+
let now: number;
|
|
1900
|
+
|
|
1901
|
+
beforeEach(() => {
|
|
1902
|
+
now = Date.now();
|
|
1903
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
afterEach(() => {
|
|
1907
|
+
vi.restoreAllMocks();
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
it('ttl: 0 stores value without TTL envelope (treated as permanent)', async () => {
|
|
1912
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-zero', version: 1 });
|
|
1913
|
+
|
|
1914
|
+
await store.set('name', 'alice', { ttl: 0 });
|
|
1915
|
+
|
|
1916
|
+
now += 999999;
|
|
1917
|
+
|
|
1918
|
+
expect(await store.get('name')).toBe('alice');
|
|
1919
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
it('ttl: -1 stores value without TTL envelope (treated as permanent)', async () => {
|
|
1923
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-neg', version: 1 });
|
|
1924
|
+
|
|
1925
|
+
await store.set('name', 'alice', { ttl: -1 });
|
|
1926
|
+
|
|
1927
|
+
now += 999999;
|
|
1928
|
+
|
|
1929
|
+
expect(await store.get('name')).toBe('alice');
|
|
1930
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
1931
|
+
});
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
describe('persist() on expired key', () => {
|
|
1936
|
+
let now: number;
|
|
1937
|
+
|
|
1938
|
+
beforeEach(() => {
|
|
1939
|
+
now = Date.now();
|
|
1940
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
afterEach(() => {
|
|
1944
|
+
vi.restoreAllMocks();
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
it('returns false and deletes the expired entry', async () => {
|
|
1949
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'persist-expired', version: 1 });
|
|
1950
|
+
|
|
1951
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1952
|
+
|
|
1953
|
+
now += 10001;
|
|
1954
|
+
|
|
1955
|
+
expect(await store.persist('name')).toBe(false);
|
|
1956
|
+
expect(await store.get('name')).toBeUndefined();
|
|
1957
|
+
});
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
describe('ttl() on expired key', () => {
|
|
1962
|
+
let now: number;
|
|
1963
|
+
|
|
1964
|
+
beforeEach(() => {
|
|
1965
|
+
now = Date.now();
|
|
1966
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
afterEach(() => {
|
|
1970
|
+
vi.restoreAllMocks();
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
it('returns -1 and deletes the expired entry', async () => {
|
|
1975
|
+
let store = createLocal<TestData>({ driver: DriverType.Memory, name: 'ttl-expired', version: 1 });
|
|
1976
|
+
|
|
1977
|
+
await store.set('name', 'alice', { ttl: 10000 });
|
|
1978
|
+
|
|
1979
|
+
now += 10001;
|
|
1980
|
+
|
|
1981
|
+
expect(await store.ttl('name')).toBe(-1);
|
|
1982
|
+
expect(await store.get('name')).toBeUndefined();
|
|
1983
|
+
});
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
describe('Migration error handling', () => {
|
|
1988
|
+
|
|
1989
|
+
let migrationErrId = 0;
|
|
1990
|
+
|
|
1991
|
+
function meuid() {
|
|
1992
|
+
return `migration-err-${++migrationErrId}`;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
it('throwing migration makes store methods reject', async () => {
|
|
1997
|
+
type V2 = { name: string };
|
|
1998
|
+
|
|
1999
|
+
let name = meuid();
|
|
2000
|
+
|
|
2001
|
+
let v1 = createLocal<{ name: string }>({ driver: DriverType.Memory, name, version: 1 });
|
|
2002
|
+
|
|
2003
|
+
await v1.set('name', 'alice');
|
|
2004
|
+
|
|
2005
|
+
let v2 = createLocal<V2>({
|
|
2006
|
+
driver: DriverType.Memory,
|
|
2007
|
+
migrations: {
|
|
2008
|
+
2: async () => { throw new Error('migration failed'); }
|
|
2009
|
+
},
|
|
2010
|
+
name,
|
|
2011
|
+
version: 2
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
await expect(v2.get('name')).rejects.toThrow('migration failed');
|
|
2015
|
+
});
|
|
2016
|
+
});
|
package/tests/lz.ts
CHANGED
|
@@ -321,4 +321,51 @@ describe('LZ Compression', () => {
|
|
|
321
321
|
expect(decompress(compressed)).toBe(input);
|
|
322
322
|
});
|
|
323
323
|
});
|
|
324
|
+
|
|
325
|
+
describe('error handling', () => {
|
|
326
|
+
it('throws on truncated compressed input', () => {
|
|
327
|
+
let compressed = compress('hello world this is a test string'),
|
|
328
|
+
truncated = compressed.substring(0, Math.floor(compressed.length / 2));
|
|
329
|
+
|
|
330
|
+
expect(() => decompress(truncated)).toThrow('LZ: unexpected end of compressed data');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('throws on invalid decompression code', () => {
|
|
334
|
+
// Craft a compressed string with a valid header followed by an invalid code:
|
|
335
|
+
// Start with a valid literal (code=0, 8-bit char 'a'=97), then inject a code
|
|
336
|
+
// value that exceeds the current dictionary size.
|
|
337
|
+
// At that point: dictSize=4, numBits=2, valid codes: 0,1,2,3
|
|
338
|
+
// We need a code >= 4 which is impossible in 2 bits, so we need to grow
|
|
339
|
+
// the dictionary first. Instead, use a real compressed stream and corrupt it.
|
|
340
|
+
let compressed = compress('abcdefghijklmnop'),
|
|
341
|
+
chars = [...compressed.slice(0, -1)];
|
|
342
|
+
|
|
343
|
+
// Corrupt a middle byte to inject invalid codes
|
|
344
|
+
if (chars.length > 3) {
|
|
345
|
+
chars[2] = String.fromCharCode(chars[2].charCodeAt(0) ^ 0x7F);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let corrupted = chars.join('') + compressed[compressed.length - 1];
|
|
349
|
+
|
|
350
|
+
expect(() => decompress(corrupted)).toThrow();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('throws when decompressed output exceeds size limit', () => {
|
|
354
|
+
// Use a moderately sized repetitive input that compresses quickly
|
|
355
|
+
// but decompresses to >10MB by building a string just over the limit
|
|
356
|
+
let chunk = 'abcdef'.repeat(100),
|
|
357
|
+
input = chunk.repeat(Math.ceil(10_485_761 / chunk.length) + 1);
|
|
358
|
+
|
|
359
|
+
let compressed = compress(input);
|
|
360
|
+
|
|
361
|
+
expect(() => decompress(compressed)).toThrow('LZ: decompressed output exceeds size limit');
|
|
362
|
+
}, 60000);
|
|
363
|
+
|
|
364
|
+
it('100KB repeated data round-trips without triggering size limit', () => {
|
|
365
|
+
let input = 'a'.repeat(100_000),
|
|
366
|
+
compressed = compress(input);
|
|
367
|
+
|
|
368
|
+
expect(decompress(compressed)).toBe(input);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
324
371
|
});
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# Test Audit: @esportsplus/web-storage
|
|
2
|
-
|
|
3
|
-
## Summary
|
|
4
|
-
- Source modules: 8
|
|
5
|
-
- Tested modules: 8 (100%)
|
|
6
|
-
- Benchmarked modules: 0 (0%)
|
|
7
|
-
- Total tests: 253
|
|
8
|
-
- Total gaps found: 23
|
|
9
|
-
|
|
10
|
-
## Missing Tests (Priority Order)
|
|
11
|
-
|
|
12
|
-
| Module | Export / Path | Type | Risk |
|
|
13
|
-
|--------|-------------|------|------|
|
|
14
|
-
| Local<T> + SessionStorage | All Local<T> methods with SS driver | integration | HIGH — only 1 factory test exists for SS; zero method coverage at Local<T> layer |
|
|
15
|
-
| Local<T> + Memory | TTL methods (ttl, persist, cleanup) | integration | HIGH — TTL logic is driver-agnostic but untested against Memory driver |
|
|
16
|
-
| Local<T> + Memory | get(key, factory) | integration | MED — factory tested for IDB/LS but not Memory |
|
|
17
|
-
| Local<T> + Memory | encryption (with secret) | integration | MED — no encryption round-trip tests for Memory driver |
|
|
18
|
-
| Local<T> + Memory | filter, only, map, length | integration | MED — bulk read operations untested at Local<T> layer for Memory |
|
|
19
|
-
| Local<T> | persist() on non-existent key | edge case | MED — returns false by code inspection, no test |
|
|
20
|
-
| Local<T> | persist() on already-permanent key | edge case | LOW — returns true, no test |
|
|
21
|
-
| Local<T> | cleanup() subscription notifications | integration | MED — validator flagged: cleanup fires notify but no test covers it |
|
|
22
|
-
| Local<T> | get() TTL expiry + subscription | edge case | MED — expired get does fire-and-forget delete but bypasses subscription-aware delete path; behavior unclear |
|
|
23
|
-
| Local<T> | count() with VERSION_KEY present | edge case | MED — should return count-1 when migrations active; no test |
|
|
24
|
-
| Local<T> | keys() with VERSION_KEY present | edge case | MED — should exclude __version__; no test |
|
|
25
|
-
| Local<T> + encryption | get(key, factory) + encryption | integration | LOW — factory + encrypt combo untested |
|
|
26
|
-
| Local<T> + LS | compression + encryption combined | integration | MED — encrypted ciphertext stored via driver which may attempt compression; round-trip untested |
|
|
27
|
-
|
|
28
|
-
## Shallow Tests
|
|
29
|
-
|
|
30
|
-
| Module | Export | Covered | Missing |
|
|
31
|
-
|--------|--------|---------|---------|
|
|
32
|
-
| Local<T>.persist() | IDB, LS | happy path (has TTL, removes it) | non-existent key, already-permanent key, expired key |
|
|
33
|
-
| Local<T>.cleanup() | IDB, LS | removes expired entries | empty store, no expired entries, subscription notifications |
|
|
34
|
-
| Local<T>.clear() | IDB, LS, Memory | clears all + notifies | VERSION_KEY preserved after clear (migration stores re-init) |
|
|
35
|
-
| Local<T>.subscribe() | Memory | set, delete, replace, clear, unsubscribe | cleanup notifications, factory-triggered notifications, TTL expiry notifications |
|
|
36
|
-
| Local<T>.map() | IDB, LS, Memory | iterates entries | TTL filtering + VERSION_KEY filtering combined |
|
|
37
|
-
| LZ compress/decompress | round-trip | all string types | very large strings (>100KB), strings that produce larger output than input (random/high-entropy) |
|
|
38
|
-
| LocalStorageDriver.parse() | error handling | corrupted compressed data | corrupted non-compressed JSON, null byte in stored data |
|
|
39
|
-
|
|
40
|
-
## Missing Benchmarks
|
|
41
|
-
|
|
42
|
-
No benchmark infrastructure exists. For a storage library, benchmarks would be useful for:
|
|
43
|
-
|
|
44
|
-
| Module | Export | Reason |
|
|
45
|
-
|--------|--------|--------|
|
|
46
|
-
| LZ compress/decompress | compress() | Called on every localStorage write ≥100 bytes |
|
|
47
|
-
| LZ compress/decompress | decompress() | Called on every localStorage read of compressed data |
|
|
48
|
-
| LocalStorageDriver | set/get | Hot path for localStorage operations |
|
|
49
|
-
| IndexedDBDriver | set/get/all | Async I/O operations, would reveal contention |
|
|
50
|
-
| Local<T> | set with encryption | Encryption + serialization overhead |
|
|
51
|
-
|
|
52
|
-
## Stale Tests
|
|
53
|
-
|
|
54
|
-
None found. All test references match current exports.
|
|
55
|
-
|
|
56
|
-
## Recommendations
|
|
57
|
-
|
|
58
|
-
### Priority 1: SessionStorage Local<T> integration (HIGH)
|
|
59
|
-
The sessionStorage driver has full driver-level tests (36) but almost zero Local<T> integration tests (just 1 factory test). Add at minimum: set/get, all, delete, clear, count, keys — mirroring the existing Memory driver block. Encryption and TTL should also be tested since the driver shares serialization logic with localStorage but includes compression.
|
|
60
|
-
|
|
61
|
-
### Priority 2: Memory driver feature coverage (HIGH)
|
|
62
|
-
TTL, persist, cleanup, get(key, factory), encryption, and bulk read operations are untested at the Local<T> layer for the Memory driver. Since Memory is the recommended driver for unit testing, these gaps are ironic — users testing their own code with Memory may hit untested paths.
|
|
63
|
-
|
|
64
|
-
### Priority 3: Cross-feature edge cases (MED)
|
|
65
|
-
- cleanup() + subscription notifications
|
|
66
|
-
- get() TTL expiry + subscription side-effects
|
|
67
|
-
- count()/keys() with VERSION_KEY present (migrations active)
|
|
68
|
-
- Compression + encryption combined round-trip
|
|
69
|
-
- persist() on non-existent and already-permanent keys
|
|
70
|
-
|
|
71
|
-
### Priority 4: LZ compression boundaries (LOW)
|
|
72
|
-
- Very large strings (100KB+)
|
|
73
|
-
- High-entropy strings that don't compress
|
|
74
|
-
- Explicit test that compression never increases size by more than a bounded amount
|