@coherent.js/state 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,90 +1,40 @@
1
1
  // src/state-persistence.js
2
- var LocalStorageAdapter = class {
3
- constructor() {
4
- this.available = typeof localStorage !== "undefined";
2
+ var WebStorageAdapter = class {
3
+ constructor(storageName) {
4
+ this.storageName = storageName;
5
+ this.available = typeof globalThis[storageName] !== "undefined" && globalThis[storageName] !== null;
6
+ }
7
+ get storage() {
8
+ return globalThis[this.storageName];
5
9
  }
6
10
  async get(key) {
7
11
  if (!this.available) return null;
8
- try {
9
- return localStorage.getItem(key);
10
- } catch (error) {
11
- console.error("LocalStorage get error:", error);
12
- return null;
13
- }
12
+ return this.storage.getItem(key);
14
13
  }
15
14
  async set(key, value) {
16
15
  if (!this.available) return false;
17
- try {
18
- localStorage.setItem(key, value);
19
- return true;
20
- } catch (error) {
21
- console.error("LocalStorage set error:", error);
22
- return false;
23
- }
16
+ this.storage.setItem(key, value);
17
+ return true;
24
18
  }
25
19
  async remove(key) {
26
20
  if (!this.available) return false;
27
- try {
28
- localStorage.removeItem(key);
29
- return true;
30
- } catch (error) {
31
- console.error("LocalStorage remove error:", error);
32
- return false;
33
- }
21
+ this.storage.removeItem(key);
22
+ return true;
34
23
  }
35
24
  async clear() {
36
25
  if (!this.available) return false;
37
- try {
38
- localStorage.clear();
39
- return true;
40
- } catch (error) {
41
- console.error("LocalStorage clear error:", error);
42
- return false;
43
- }
26
+ this.storage.clear();
27
+ return true;
44
28
  }
45
29
  };
46
- var SessionStorageAdapter = class {
30
+ var LocalStorageAdapter = class extends WebStorageAdapter {
47
31
  constructor() {
48
- this.available = typeof sessionStorage !== "undefined";
49
- }
50
- async get(key) {
51
- if (!this.available) return null;
52
- try {
53
- return sessionStorage.getItem(key);
54
- } catch (error) {
55
- console.error("SessionStorage get error:", error);
56
- return null;
57
- }
58
- }
59
- async set(key, value) {
60
- if (!this.available) return false;
61
- try {
62
- sessionStorage.setItem(key, value);
63
- return true;
64
- } catch (error) {
65
- console.error("SessionStorage set error:", error);
66
- return false;
67
- }
68
- }
69
- async remove(key) {
70
- if (!this.available) return false;
71
- try {
72
- sessionStorage.removeItem(key);
73
- return true;
74
- } catch (error) {
75
- console.error("SessionStorage remove error:", error);
76
- return false;
77
- }
32
+ super("localStorage");
78
33
  }
79
- async clear() {
80
- if (!this.available) return false;
81
- try {
82
- sessionStorage.clear();
83
- return true;
84
- } catch (error) {
85
- console.error("SessionStorage clear error:", error);
86
- return false;
87
- }
34
+ };
35
+ var SessionStorageAdapter = class extends WebStorageAdapter {
36
+ constructor() {
37
+ super("sessionStorage");
88
38
  }
89
39
  };
90
40
  var IndexedDBAdapter = class {
@@ -93,19 +43,21 @@ var IndexedDBAdapter = class {
93
43
  this.storeName = storeName;
94
44
  this.available = typeof indexedDB !== "undefined";
95
45
  this.db = null;
46
+ this.opening = null;
96
47
  }
97
- async init() {
98
- if (!this.available) return false;
99
- if (this.db) return true;
48
+ /**
49
+ * Open the database, creating the store in an upgrade. Without `version`,
50
+ * opens the current version (creating version 1 for a new database).
51
+ */
52
+ open(version) {
100
53
  return new Promise((resolve, reject) => {
101
- const request = indexedDB.open(this.dbName, 1);
54
+ const request = version === void 0 ? indexedDB.open(this.dbName) : indexedDB.open(this.dbName, version);
102
55
  request.onerror = () => {
103
56
  console.error("IndexedDB open error:", request.error);
104
57
  reject(request.error);
105
58
  };
106
59
  request.onsuccess = () => {
107
- this.db = request.result;
108
- resolve(true);
60
+ resolve(request.result);
109
61
  };
110
62
  request.onupgradeneeded = (event) => {
111
63
  const db = event.target.result;
@@ -115,6 +67,27 @@ var IndexedDBAdapter = class {
115
67
  };
116
68
  });
117
69
  }
70
+ async init() {
71
+ if (!this.available) return false;
72
+ if (this.db) return true;
73
+ this.opening ??= (async () => {
74
+ let db = await this.open();
75
+ if (!db.objectStoreNames.contains(this.storeName)) {
76
+ const version = db.version + 1;
77
+ db.close();
78
+ db = await this.open(version);
79
+ }
80
+ db.onversionchange = () => {
81
+ db.close();
82
+ if (this.db === db) this.db = null;
83
+ };
84
+ this.db = db;
85
+ return true;
86
+ })().finally(() => {
87
+ this.opening = null;
88
+ });
89
+ return this.opening;
90
+ }
118
91
  async get(key) {
119
92
  if (!this.available) return null;
120
93
  await this.init();
@@ -200,47 +173,85 @@ var MemoryAdapter = class {
200
173
  return true;
201
174
  }
202
175
  };
203
- var SimpleEncryption = class {
204
- constructor(key) {
205
- this.key = key || "default-key";
176
+ var ServerAdapter = class {
177
+ constructor() {
178
+ this.available = false;
179
+ }
180
+ async get() {
181
+ return null;
182
+ }
183
+ async set() {
184
+ return false;
185
+ }
186
+ async remove() {
187
+ return false;
188
+ }
189
+ async clear() {
190
+ return false;
206
191
  }
207
- encrypt(text) {
208
- let result = "";
209
- for (let i = 0; i < text.length; i++) {
210
- result += String.fromCharCode(
211
- text.charCodeAt(i) ^ this.key.charCodeAt(i % this.key.length)
192
+ };
193
+ function toBase64(bytes) {
194
+ let binary = "";
195
+ for (let i = 0; i < bytes.length; i += 32768) {
196
+ binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
197
+ }
198
+ return btoa(binary);
199
+ }
200
+ function fromBase64(encoded) {
201
+ const binary = atob(encoded);
202
+ const bytes = new Uint8Array(binary.length);
203
+ for (let i = 0; i < binary.length; i++) {
204
+ bytes[i] = binary.charCodeAt(i);
205
+ }
206
+ return bytes;
207
+ }
208
+ var XorObfuscation = class {
209
+ constructor(key) {
210
+ if (typeof key !== "string" || key.length === 0) {
211
+ throw new TypeError(
212
+ "createPersistentState: `encrypt: true` requires a non-empty `encryptionKey`. It is XOR obfuscation, not encryption; there is no default key."
212
213
  );
213
214
  }
214
- return btoa(result);
215
- }
216
- decrypt(encrypted) {
217
- const text = atob(encrypted);
218
- let result = "";
219
- for (let i = 0; i < text.length; i++) {
220
- result += String.fromCharCode(
221
- text.charCodeAt(i) ^ this.key.charCodeAt(i % this.key.length)
222
- );
215
+ this.keyBytes = new globalThis.TextEncoder().encode(key);
216
+ }
217
+ xor(bytes) {
218
+ for (let i = 0; i < bytes.length; i++) {
219
+ bytes[i] ^= this.keyBytes[i % this.keyBytes.length];
223
220
  }
224
- return result;
221
+ return bytes;
222
+ }
223
+ encode(text) {
224
+ return toBase64(this.xor(new globalThis.TextEncoder().encode(text)));
225
+ }
226
+ decode(encoded) {
227
+ return new globalThis.TextDecoder().decode(this.xor(fromBase64(encoded)));
225
228
  }
226
229
  };
227
- function createStorageAdapter(type) {
230
+ function createStorageAdapter(type, options = {}) {
228
231
  switch (type) {
229
232
  case "localStorage":
230
233
  return new LocalStorageAdapter();
231
234
  case "sessionStorage":
232
235
  return new SessionStorageAdapter();
233
236
  case "indexedDB":
234
- return new IndexedDBAdapter();
237
+ return new IndexedDBAdapter(options.dbName ?? void 0, options.storeName ?? void 0);
235
238
  case "memory":
236
239
  return new MemoryAdapter();
237
240
  default:
238
241
  return new LocalStorageAdapter();
239
242
  }
240
243
  }
244
+ function createInstanceId() {
245
+ const cryptoApi = globalThis.crypto;
246
+ if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
247
+ return cryptoApi.randomUUID();
248
+ }
249
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
250
+ }
241
251
  function createPersistentState(initialState = {}, options = {}) {
242
252
  const opts = {
243
253
  storage: "localStorage",
254
+ adapter: null,
244
255
  key: "coherent-state",
245
256
  debounce: true,
246
257
  debounceDelay: 300,
@@ -260,11 +271,30 @@ function createPersistentState(initialState = {}, options = {}) {
260
271
  crossTab: false,
261
272
  ...options
262
273
  };
263
- const adapter = createStorageAdapter(opts.storage);
264
- const encryption = opts.encrypt ? new SimpleEncryption(opts.encryptionKey) : null;
274
+ const onServer = typeof window === "undefined";
275
+ const obfuscation = opts.encrypt ? new XorObfuscation(opts.encryptionKey) : null;
276
+ let adapter;
277
+ if (opts.adapter) {
278
+ adapter = opts.adapter;
279
+ } else if (onServer && opts.storage !== "memory") {
280
+ adapter = new ServerAdapter();
281
+ } else {
282
+ adapter = createStorageAdapter(opts.storage, opts);
283
+ }
284
+ const instanceId = createInstanceId();
265
285
  let state = { ...initialState };
266
286
  let saveTimeout = null;
287
+ let destroyed = false;
267
288
  const listeners = /* @__PURE__ */ new Set();
289
+ let initialRestorePending = false;
290
+ const touchedKeys = /* @__PURE__ */ new Set();
291
+ function reportError(error) {
292
+ if (opts.onError) {
293
+ opts.onError(error);
294
+ } else {
295
+ console.error("State persistence error:", error);
296
+ }
297
+ }
268
298
  function filterKeys(obj) {
269
299
  if (!obj || typeof obj !== "object") return obj;
270
300
  if (opts.include && Array.isArray(opts.include)) {
@@ -285,11 +315,31 @@ function createPersistentState(initialState = {}, options = {}) {
285
315
  }
286
316
  return obj;
287
317
  }
288
- async function save(immediate = false) {
289
- if (opts.debounce && !immediate) {
290
- clearTimeout(saveTimeout);
291
- saveTimeout = setTimeout(() => save(true), opts.debounceDelay);
292
- return;
318
+ let channel = null;
319
+ if (opts.crossTab && !onServer && typeof BroadcastChannel !== "undefined") {
320
+ channel = new BroadcastChannel(`coherent-state-sync:${opts.key}`);
321
+ channel.onmessage = (event) => {
322
+ const message = event.data;
323
+ if (destroyed || !message || message.type !== "state-update" || message.source === instanceId) {
324
+ return;
325
+ }
326
+ const oldState = { ...state };
327
+ state = { ...state, ...message.state };
328
+ notifyListeners(oldState, state);
329
+ };
330
+ channel.unref?.();
331
+ }
332
+ function broadcast(filteredState) {
333
+ if (!channel) return;
334
+ try {
335
+ channel.postMessage({ type: "state-update", source: instanceId, state: filteredState });
336
+ } catch (error) {
337
+ reportError(error);
338
+ }
339
+ }
340
+ async function write() {
341
+ if (adapter.available === false) {
342
+ return false;
293
343
  }
294
344
  try {
295
345
  const filteredState = filterKeys(state);
@@ -301,31 +351,45 @@ function createPersistentState(initialState = {}, options = {}) {
301
351
  ttl: opts.ttl
302
352
  };
303
353
  let dataString = JSON.stringify(data);
304
- if (encryption) {
305
- dataString = encryption.encrypt(dataString);
354
+ if (obfuscation) {
355
+ dataString = obfuscation.encode(dataString);
356
+ }
357
+ const stored = await adapter.set(opts.key, dataString);
358
+ if (stored === false) {
359
+ throw new Error(`State "${opts.key}" could not be written to storage`);
306
360
  }
307
- await adapter.set(opts.key, dataString);
308
361
  if (opts.onSave) {
309
362
  opts.onSave(filteredState);
310
363
  }
311
- if (opts.crossTab && typeof BroadcastChannel !== "undefined") {
312
- const channel = new BroadcastChannel("coherent-state-sync");
313
- channel.postMessage({ type: "state-update", state: filteredState });
314
- channel.close();
315
- }
364
+ broadcast(filteredState);
365
+ return true;
316
366
  } catch (error) {
317
- console.error("State save error:", error);
318
- if (opts.onError) {
319
- opts.onError(error);
320
- }
367
+ reportError(error);
368
+ return false;
321
369
  }
322
370
  }
371
+ function save(immediate = false) {
372
+ if (destroyed) {
373
+ return Promise.resolve(false);
374
+ }
375
+ if (opts.debounce && !immediate) {
376
+ clearTimeout(saveTimeout);
377
+ saveTimeout = setTimeout(() => {
378
+ saveTimeout = null;
379
+ write();
380
+ }, opts.debounceDelay);
381
+ return void 0;
382
+ }
383
+ clearTimeout(saveTimeout);
384
+ saveTimeout = null;
385
+ return write();
386
+ }
323
387
  async function load() {
324
388
  try {
325
389
  let dataString = await adapter.get(opts.key);
326
390
  if (!dataString) return null;
327
- if (encryption) {
328
- dataString = encryption.decrypt(dataString);
391
+ if (obfuscation) {
392
+ dataString = obfuscation.decode(dataString);
329
393
  }
330
394
  const data = JSON.parse(dataString);
331
395
  if (data.ttl && data.timestamp) {
@@ -348,10 +412,7 @@ function createPersistentState(initialState = {}, options = {}) {
348
412
  }
349
413
  return loadedState;
350
414
  } catch (error) {
351
- console.error("State load error:", error);
352
- if (opts.onError) {
353
- opts.onError(error);
354
- }
415
+ reportError(error);
355
416
  return null;
356
417
  }
357
418
  }
@@ -368,6 +429,20 @@ function createPersistentState(initialState = {}, options = {}) {
368
429
  }
369
430
  });
370
431
  }
432
+ function applyLoaded(loaded, skipTouched) {
433
+ if (!loaded || typeof loaded !== "object") {
434
+ return false;
435
+ }
436
+ const updates = Object.fromEntries(
437
+ Object.entries(loaded).filter(([key]) => !skipTouched || !touchedKeys.has(key))
438
+ );
439
+ if (Object.keys(updates).length > 0) {
440
+ const oldState = { ...state };
441
+ state = { ...state, ...updates };
442
+ notifyListeners(oldState, state);
443
+ }
444
+ return true;
445
+ }
371
446
  function getState(key) {
372
447
  return key ? state[key] : { ...state };
373
448
  }
@@ -376,6 +451,9 @@ function createPersistentState(initialState = {}, options = {}) {
376
451
  if (typeof updates === "function") {
377
452
  updates = updates(oldState);
378
453
  }
454
+ if (initialRestorePending && updates && typeof updates === "object") {
455
+ for (const key of Object.keys(updates)) touchedKeys.add(key);
456
+ }
379
457
  state = { ...state, ...updates };
380
458
  notifyListeners(oldState, state);
381
459
  if (persist2) {
@@ -384,6 +462,10 @@ function createPersistentState(initialState = {}, options = {}) {
384
462
  }
385
463
  function resetState(persist2 = true) {
386
464
  const oldState = { ...state };
465
+ if (initialRestorePending) {
466
+ for (const key of Object.keys(oldState)) touchedKeys.add(key);
467
+ for (const key of Object.keys(initialState)) touchedKeys.add(key);
468
+ }
387
469
  state = { ...initialState };
388
470
  notifyListeners(oldState, state);
389
471
  if (persist2) {
@@ -391,33 +473,44 @@ function createPersistentState(initialState = {}, options = {}) {
391
473
  }
392
474
  }
393
475
  async function clearStorage() {
394
- await adapter.remove(opts.key);
476
+ try {
477
+ await adapter.remove(opts.key);
478
+ } catch (error) {
479
+ reportError(error);
480
+ }
395
481
  }
396
482
  async function persist() {
397
- await save(true);
483
+ return save(true);
398
484
  }
399
485
  async function restore() {
400
- const loaded = await load();
401
- if (loaded) {
402
- const oldState = { ...state };
403
- state = { ...state, ...loaded };
404
- notifyListeners(oldState, state);
405
- return true;
486
+ return applyLoaded(await load(), false);
487
+ }
488
+ async function destroy() {
489
+ if (destroyed) return;
490
+ const pending = saveTimeout !== null;
491
+ clearTimeout(saveTimeout);
492
+ saveTimeout = null;
493
+ if (pending) {
494
+ await write();
406
495
  }
407
- return false;
408
- }
409
- if (opts.crossTab && typeof BroadcastChannel !== "undefined") {
410
- const channel = new BroadcastChannel("coherent-state-sync");
411
- channel.onmessage = (event) => {
412
- if (event.data.type === "state-update") {
413
- const oldState = { ...state };
414
- state = { ...state, ...event.data.state };
415
- notifyListeners(oldState, state);
416
- }
417
- };
418
- }
419
- if (opts.storage !== "memory") {
420
- restore();
496
+ destroyed = true;
497
+ channel?.close();
498
+ channel = null;
499
+ listeners.clear();
500
+ }
501
+ let ready;
502
+ if (opts.storage !== "memory" || opts.adapter) {
503
+ initialRestorePending = true;
504
+ ready = load().then((loaded) => {
505
+ initialRestorePending = false;
506
+ if (destroyed) return false;
507
+ const restored = applyLoaded(loaded, true);
508
+ if (restored && touchedKeys.size > 0) save();
509
+ touchedKeys.clear();
510
+ return restored;
511
+ });
512
+ } else {
513
+ ready = Promise.resolve(false);
421
514
  }
422
515
  return {
423
516
  getState,
@@ -429,6 +522,9 @@ function createPersistentState(initialState = {}, options = {}) {
429
522
  clearStorage,
430
523
  load,
431
524
  save: () => save(true),
525
+ destroy,
526
+ /** Settles once the automatic restore on creation is done */
527
+ ready,
432
528
  get adapter() {
433
529
  return adapter;
434
530
  }