@drakkar.software/starfish-client 1.19.0 → 1.19.2

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,266 +1,801 @@
1
+ // src/bindings/zustand.ts
1
2
  import { createStore } from "zustand/vanilla";
2
3
  import { useStore } from "zustand";
3
- import { persist, subscribeWithSelector, createJSONStorage, } from "zustand/middleware";
4
- import { useEffect, useRef, useState, useCallback } from "react";
5
- import { StarfishClient } from "../client.js";
6
- import { SyncManager } from "../sync.js";
7
- import { setupCrossTabSync } from "../broadcast.js";
8
- export function createStarfishStore(options) {
9
- const { name, syncManager, storage } = options;
10
- const storeCreator = (rawSet, get) => {
11
- const set = rawSet;
12
- return {
13
- data: {},
14
- syncing: false,
15
- online: true,
16
- dirty: false,
17
- error: null,
18
- pull: async () => {
19
- set({ syncing: true, error: null }, false, "pull/start");
20
- try {
21
- await syncManager.pull();
22
- const newData = syncManager.getData();
23
- set({ data: newData, syncing: false }, false, "pull/success");
24
- // Fire after state update so domain stores can read the updated Starfish state if needed.
25
- // Calling set() inside onRemoteUpdate does NOT re-enter pull(), so no feedback loop.
26
- options.onRemoteUpdate?.(newData);
27
- }
28
- catch (err) {
29
- set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
30
- }
31
- },
32
- set: (modifier) => {
33
- try {
34
- const next = options.produce
35
- ? options.produce(get().data, modifier)
36
- : modifier(get().data);
37
- set({ data: next, dirty: true, error: null }, false, "set");
38
- if (get().online)
39
- get().flush().catch(() => { });
40
- }
41
- catch (err) {
42
- set({ error: err instanceof Error ? err.message : String(err) }, false, "set/error");
43
- }
44
- },
45
- restore: (data) => {
46
- set({ data }, false, "restore");
47
- },
48
- flush: async () => {
49
- if (get().syncing || !get().dirty)
50
- return;
51
- set({ syncing: true, error: null }, false, "flush/start");
52
- try {
53
- await syncManager.push(get().data);
54
- set({ data: syncManager.getData(), syncing: false, dirty: false }, false, "flush/success");
55
- }
56
- catch (err) {
57
- set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
58
- }
59
- },
60
- setOnline: (online) => {
61
- set({ online }, false, "setOnline");
62
- if (online && get().dirty)
63
- get().flush().catch(() => { });
64
- },
65
- };
4
+
5
+ // ../../../node_modules/.pnpm/zustand@5.0.11_@types+react@19.2.14_immer@11.1.4_react@19.2.4_use-sync-external-store@1.6.0_react@19.2.4_/node_modules/zustand/esm/middleware.mjs
6
+ var subscribeWithSelectorImpl = (fn) => (set, get, api) => {
7
+ const origSubscribe = api.subscribe;
8
+ api.subscribe = ((selector, optListener, options) => {
9
+ let listener = selector;
10
+ if (optListener) {
11
+ const equalityFn = (options == null ? void 0 : options.equalityFn) || Object.is;
12
+ let currentSlice = selector(api.getState());
13
+ listener = (state) => {
14
+ const nextSlice = selector(state);
15
+ if (!equalityFn(currentSlice, nextSlice)) {
16
+ const previousSlice = currentSlice;
17
+ optListener(currentSlice = nextSlice, previousSlice);
18
+ }
19
+ };
20
+ if (options == null ? void 0 : options.fireImmediately) {
21
+ optListener(currentSlice, currentSlice);
22
+ }
23
+ }
24
+ return origSubscribe(listener);
25
+ });
26
+ const initialState = fn(set, get, api);
27
+ return initialState;
28
+ };
29
+ var subscribeWithSelector = subscribeWithSelectorImpl;
30
+ function createJSONStorage(getStorage, options) {
31
+ let storage;
32
+ try {
33
+ storage = getStorage();
34
+ } catch (e) {
35
+ return;
36
+ }
37
+ const persistStorage = {
38
+ getItem: (name) => {
39
+ var _a;
40
+ const parse = (str2) => {
41
+ if (str2 === null) {
42
+ return null;
43
+ }
44
+ return JSON.parse(str2, options == null ? void 0 : options.reviver);
45
+ };
46
+ const str = (_a = storage.getItem(name)) != null ? _a : null;
47
+ if (str instanceof Promise) {
48
+ return str.then(parse);
49
+ }
50
+ return parse(str);
51
+ },
52
+ setItem: (name, newValue) => storage.setItem(name, JSON.stringify(newValue, options == null ? void 0 : options.replacer)),
53
+ removeItem: (name) => storage.removeItem(name)
54
+ };
55
+ return persistStorage;
56
+ }
57
+ var toThenable = (fn) => (input) => {
58
+ try {
59
+ const result = fn(input);
60
+ if (result instanceof Promise) {
61
+ return result;
62
+ }
63
+ return {
64
+ then(onFulfilled) {
65
+ return toThenable(onFulfilled)(result);
66
+ },
67
+ catch(_onRejected) {
68
+ return this;
69
+ }
70
+ };
71
+ } catch (e) {
72
+ return {
73
+ then(_onFulfilled) {
74
+ return this;
75
+ },
76
+ catch(onRejected) {
77
+ return toThenable(onRejected)(e);
78
+ }
66
79
  };
67
- const withPersist = storage === false
68
- ? storeCreator
69
- : persist(storeCreator, {
70
- name: `starfish-${name}`,
71
- storage: storage ? createJSONStorage(() => storage) : undefined,
72
- partialize: (state) => ({
73
- data: state.data,
74
- dirty: state.dirty,
75
- }),
80
+ }
81
+ };
82
+ var persistImpl = (config, baseOptions) => (set, get, api) => {
83
+ let options = {
84
+ storage: createJSONStorage(() => window.localStorage),
85
+ partialize: (state) => state,
86
+ version: 0,
87
+ merge: (persistedState, currentState) => ({
88
+ ...currentState,
89
+ ...persistedState
90
+ }),
91
+ ...baseOptions
92
+ };
93
+ let hasHydrated = false;
94
+ let hydrationVersion = 0;
95
+ const hydrationListeners = /* @__PURE__ */ new Set();
96
+ const finishHydrationListeners = /* @__PURE__ */ new Set();
97
+ let storage = options.storage;
98
+ if (!storage) {
99
+ return config(
100
+ (...args) => {
101
+ console.warn(
102
+ `[zustand persist middleware] Unable to update item '${options.name}', the given storage is currently unavailable.`
103
+ );
104
+ set(...args);
105
+ },
106
+ get,
107
+ api
108
+ );
109
+ }
110
+ const setItem = () => {
111
+ const state = options.partialize({ ...get() });
112
+ return storage.setItem(options.name, {
113
+ state,
114
+ version: options.version
115
+ });
116
+ };
117
+ const savedSetState = api.setState;
118
+ api.setState = (state, replace) => {
119
+ savedSetState(state, replace);
120
+ return setItem();
121
+ };
122
+ const configResult = config(
123
+ (...args) => {
124
+ set(...args);
125
+ return setItem();
126
+ },
127
+ get,
128
+ api
129
+ );
130
+ api.getInitialState = () => configResult;
131
+ let stateFromStorage;
132
+ const hydrate = () => {
133
+ var _a, _b;
134
+ if (!storage) return;
135
+ const currentVersion = ++hydrationVersion;
136
+ hasHydrated = false;
137
+ hydrationListeners.forEach((cb) => {
138
+ var _a2;
139
+ return cb((_a2 = get()) != null ? _a2 : configResult);
140
+ });
141
+ const postRehydrationCallback = ((_b = options.onRehydrateStorage) == null ? void 0 : _b.call(options, (_a = get()) != null ? _a : configResult)) || void 0;
142
+ return toThenable(storage.getItem.bind(storage))(options.name).then((deserializedStorageValue) => {
143
+ if (deserializedStorageValue) {
144
+ if (typeof deserializedStorageValue.version === "number" && deserializedStorageValue.version !== options.version) {
145
+ if (options.migrate) {
146
+ const migration = options.migrate(
147
+ deserializedStorageValue.state,
148
+ deserializedStorageValue.version
149
+ );
150
+ if (migration instanceof Promise) {
151
+ return migration.then((result) => [true, result]);
152
+ }
153
+ return [true, migration];
154
+ }
155
+ console.error(
156
+ `State loaded from storage couldn't be migrated since no migrate function was provided`
157
+ );
158
+ } else {
159
+ return [false, deserializedStorageValue.state];
160
+ }
161
+ }
162
+ return [false, void 0];
163
+ }).then((migrationResult) => {
164
+ var _a2;
165
+ if (currentVersion !== hydrationVersion) {
166
+ return;
167
+ }
168
+ const [migrated, migratedState] = migrationResult;
169
+ stateFromStorage = options.merge(
170
+ migratedState,
171
+ (_a2 = get()) != null ? _a2 : configResult
172
+ );
173
+ set(stateFromStorage, true);
174
+ if (migrated) {
175
+ return setItem();
176
+ }
177
+ }).then(() => {
178
+ if (currentVersion !== hydrationVersion) {
179
+ return;
180
+ }
181
+ postRehydrationCallback == null ? void 0 : postRehydrationCallback(stateFromStorage, void 0);
182
+ stateFromStorage = get();
183
+ hasHydrated = true;
184
+ finishHydrationListeners.forEach((cb) => cb(stateFromStorage));
185
+ }).catch((e) => {
186
+ if (currentVersion !== hydrationVersion) {
187
+ return;
188
+ }
189
+ postRehydrationCallback == null ? void 0 : postRehydrationCallback(void 0, e);
190
+ });
191
+ };
192
+ api.persist = {
193
+ setOptions: (newOptions) => {
194
+ options = {
195
+ ...options,
196
+ ...newOptions
197
+ };
198
+ if (newOptions.storage) {
199
+ storage = newOptions.storage;
200
+ }
201
+ },
202
+ clearStorage: () => {
203
+ storage == null ? void 0 : storage.removeItem(options.name);
204
+ },
205
+ getOptions: () => options,
206
+ rehydrate: () => hydrate(),
207
+ hasHydrated: () => hasHydrated,
208
+ onHydrate: (cb) => {
209
+ hydrationListeners.add(cb);
210
+ return () => {
211
+ hydrationListeners.delete(cb);
212
+ };
213
+ },
214
+ onFinishHydration: (cb) => {
215
+ finishHydrationListeners.add(cb);
216
+ return () => {
217
+ finishHydrationListeners.delete(cb);
218
+ };
219
+ }
220
+ };
221
+ if (!options.skipHydration) {
222
+ hydrate();
223
+ }
224
+ return stateFromStorage || configResult;
225
+ };
226
+ var persist = persistImpl;
227
+
228
+ // src/bindings/zustand.ts
229
+ import { useEffect, useRef, useState, useCallback } from "react";
230
+
231
+ // src/types.ts
232
+ var ConflictError = class extends Error {
233
+ constructor() {
234
+ super("hash_mismatch");
235
+ this.name = "ConflictError";
236
+ }
237
+ };
238
+ var StarfishHttpError = class extends Error {
239
+ constructor(status, body) {
240
+ super(`HTTP ${status}: ${body}`);
241
+ this.status = status;
242
+ this.body = body;
243
+ this.name = "StarfishHttpError";
244
+ }
245
+ };
246
+
247
+ // src/client.ts
248
+ var StarfishClient = class {
249
+ baseUrl;
250
+ auth;
251
+ fetch;
252
+ constructor(options) {
253
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
254
+ this.auth = options.auth;
255
+ this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
256
+ }
257
+ /**
258
+ * Pull synced data from the server.
259
+ * @param path - The pull endpoint path (e.g. "/pull/users/abc/settings")
260
+ * @param checkpoint - Only return data updated after this timestamp (0 = full pull)
261
+ */
262
+ async pull(path, checkpoint) {
263
+ const url = checkpoint ? `${this.baseUrl}${path}?checkpoint=${checkpoint}` : `${this.baseUrl}${path}`;
264
+ const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
265
+ const res = await this.fetch(url, {
266
+ method: "GET",
267
+ headers: { Accept: "application/json", ...authHeaders }
268
+ });
269
+ if (!res.ok) {
270
+ throw new StarfishHttpError(res.status, await res.text());
271
+ }
272
+ return res.json();
273
+ }
274
+ /**
275
+ * Push synced data to the server.
276
+ * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
277
+ * @param data - The full document data to push
278
+ * @param baseHash - Hash of the document this push is based on (null for first push)
279
+ * @param authorSignature - Optional author signature for provenance
280
+ * @throws {ConflictError} if the server detects a hash mismatch (409)
281
+ */
282
+ async push(path, data, baseHash, authorSignature) {
283
+ const body = JSON.stringify({
284
+ data,
285
+ baseHash,
286
+ ...authorSignature && { authorSignature }
287
+ });
288
+ const authHeaders = this.auth ? await this.auth({ method: "POST", path, body }) : {};
289
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
290
+ method: "POST",
291
+ headers: {
292
+ "Content-Type": "application/json",
293
+ Accept: "application/json",
294
+ ...authHeaders
295
+ },
296
+ body
297
+ });
298
+ if (res.status === 409) {
299
+ throw new ConflictError();
300
+ }
301
+ if (!res.ok) {
302
+ throw new StarfishHttpError(res.status, await res.text());
303
+ }
304
+ return res.json();
305
+ }
306
+ /**
307
+ * Pull binary data from a blob collection.
308
+ * Returns raw bytes with the content hash from the ETag header.
309
+ */
310
+ async pullBlob(path) {
311
+ const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
312
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
313
+ method: "GET",
314
+ headers: { Accept: "*/*", ...authHeaders }
315
+ });
316
+ if (!res.ok) {
317
+ throw new StarfishHttpError(res.status, await res.text());
318
+ }
319
+ const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
320
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
321
+ const data = await res.arrayBuffer();
322
+ return { data, hash: etag, contentType };
323
+ }
324
+ /**
325
+ * Push binary data to a blob collection.
326
+ * Binary collections use last-write-wins (no conflict detection).
327
+ */
328
+ async pushBlob(path, data, contentType) {
329
+ const authHeaders = this.auth ? await this.auth({ method: "POST", path, body: null }) : {};
330
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
331
+ method: "POST",
332
+ headers: {
333
+ "Content-Type": contentType,
334
+ Accept: "application/json",
335
+ ...authHeaders
336
+ },
337
+ body: data
338
+ });
339
+ if (!res.ok) {
340
+ throw new StarfishHttpError(res.status, await res.text());
341
+ }
342
+ return res.json();
343
+ }
344
+ };
345
+
346
+ // src/sync.ts
347
+ import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
348
+
349
+ // src/crypto.ts
350
+ import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
351
+ var ALGO = "AES-GCM";
352
+ function createEncryptor(secret, salt, info = "starfish-e2e") {
353
+ if (!secret) throw new Error("encryptionSecret must not be empty");
354
+ if (!salt) throw new Error("encryptionSalt must not be empty");
355
+ const keyPromise = deriveKey(secret, salt, info);
356
+ return {
357
+ async encrypt(data) {
358
+ const key = await keyPromise;
359
+ const c = getCrypto();
360
+ const b64 = getBase64();
361
+ const plaintext = new TextEncoder().encode(JSON.stringify(data));
362
+ const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
363
+ const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
364
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
365
+ combined.set(iv);
366
+ combined.set(new Uint8Array(ciphertext), iv.length);
367
+ return { [ENCRYPTED_KEY]: b64.encode(combined) };
368
+ },
369
+ async decrypt(wrapper) {
370
+ const encoded = wrapper[ENCRYPTED_KEY];
371
+ if (typeof encoded !== "string") {
372
+ throw new Error("Expected encrypted data but received unencrypted document");
373
+ }
374
+ const key = await keyPromise;
375
+ const c = getCrypto();
376
+ const b64 = getBase64();
377
+ const combined = b64.decode(encoded);
378
+ if (combined.length < IV_BYTES) {
379
+ throw new Error("Encrypted data is too short");
380
+ }
381
+ const iv = combined.slice(0, IV_BYTES);
382
+ const ciphertext = combined.slice(IV_BYTES);
383
+ try {
384
+ const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
385
+ return JSON.parse(new TextDecoder().decode(plaintext));
386
+ } catch (err) {
387
+ throw new Error("Decryption failed: data may be tampered or key is incorrect", { cause: err });
388
+ }
389
+ }
390
+ };
391
+ }
392
+
393
+ // src/validate.ts
394
+ var ValidationError = class extends Error {
395
+ constructor(errors) {
396
+ super(`Validation failed: ${errors.join("; ")}`);
397
+ this.errors = errors;
398
+ this.name = "ValidationError";
399
+ }
400
+ };
401
+
402
+ // src/sync.ts
403
+ var SyncManager = class {
404
+ client;
405
+ pullPath;
406
+ pushPath;
407
+ onConflict;
408
+ maxRetries;
409
+ encryptor;
410
+ signData;
411
+ logger;
412
+ loggerName;
413
+ validate;
414
+ lastHash = null;
415
+ lastCheckpoint = 0;
416
+ localData = {};
417
+ constructor(options) {
418
+ this.client = options.client;
419
+ this.pullPath = options.pullPath;
420
+ this.pushPath = options.pushPath;
421
+ this.onConflict = options.onConflict ?? deepMerge;
422
+ this.maxRetries = options.maxRetries ?? 3;
423
+ this.signData = options.signData;
424
+ this.logger = options.logger;
425
+ this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
426
+ this.validate = options.validate;
427
+ this.encryptor = options.encryptor ?? (options.encryptionSecret && options.encryptionSalt ? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo) : null);
428
+ }
429
+ getData() {
430
+ return { ...this.localData };
431
+ }
432
+ getHash() {
433
+ return this.lastHash;
434
+ }
435
+ getCheckpoint() {
436
+ return this.lastCheckpoint;
437
+ }
438
+ async pull() {
439
+ this.logger?.pullStart(this.loggerName);
440
+ const start = performance.now();
441
+ try {
442
+ const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
443
+ if (this.encryptor) {
444
+ const decrypted = await this.encryptor.decrypt(result.data);
445
+ this.localData = decrypted;
446
+ result.data = decrypted;
447
+ } else if (this.lastCheckpoint > 0) {
448
+ this.localData = deepMerge(this.localData, result.data);
449
+ result.data = this.localData;
450
+ } else {
451
+ this.localData = result.data;
452
+ }
453
+ this.lastHash = result.hash;
454
+ this.lastCheckpoint = result.timestamp;
455
+ this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
456
+ return result;
457
+ } catch (err) {
458
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
459
+ throw err;
460
+ }
461
+ }
462
+ async push(data) {
463
+ if (this.validate) {
464
+ const result = this.validate(data);
465
+ if (result !== true) throw new ValidationError(result);
466
+ }
467
+ this.logger?.pushStart(this.loggerName);
468
+ const start = performance.now();
469
+ let attempt = 0;
470
+ let pendingData = data;
471
+ while (attempt <= this.maxRetries) {
472
+ try {
473
+ const payload = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
474
+ const sig = this.signData ? await this.signData(stableStringify(payload)) : void 0;
475
+ const result = await this.client.push(
476
+ this.pushPath,
477
+ payload,
478
+ this.lastHash,
479
+ sig
480
+ );
481
+ this.lastHash = result.hash;
482
+ this.lastCheckpoint = result.timestamp;
483
+ this.localData = pendingData;
484
+ this.logger?.pushSuccess(this.loggerName, Math.round(performance.now() - start));
485
+ return result;
486
+ } catch (err) {
487
+ if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
488
+ this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
489
+ throw err;
490
+ }
491
+ this.logger?.conflict(this.loggerName, attempt + 1);
492
+ try {
493
+ const remote = await this.client.pull(this.pullPath);
494
+ const remoteData = this.encryptor ? await this.encryptor.decrypt(remote.data) : remote.data;
495
+ this.lastHash = remote.hash;
496
+ this.lastCheckpoint = remote.timestamp;
497
+ pendingData = this.onConflict(pendingData, remoteData);
498
+ } catch (resolveErr) {
499
+ const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
500
+ this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
501
+ throw resolveErr;
502
+ }
503
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 2e3) + Math.random() * 100));
504
+ attempt++;
505
+ }
506
+ }
507
+ throw new ConflictError();
508
+ }
509
+ async update(modifier) {
510
+ await this.pull();
511
+ const updated = modifier(this.localData);
512
+ return this.push(updated);
513
+ }
514
+ };
515
+
516
+ // src/broadcast.ts
517
+ function setupBroadcastSync(store, name) {
518
+ const channel = new BroadcastChannel(`starfish-${name}`);
519
+ let lastReceivedData = null;
520
+ channel.onmessage = (event) => {
521
+ const payload = event.data;
522
+ if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object") return;
523
+ lastReceivedData = payload.data;
524
+ store.setState({ data: payload.data, dirty: !!payload.dirty });
525
+ };
526
+ const unsub = store.subscribe((state, prev) => {
527
+ if (state.data === lastReceivedData) return;
528
+ if (state.data !== prev.data || state.dirty !== prev.dirty) {
529
+ try {
530
+ channel.postMessage({ data: state.data, dirty: state.dirty });
531
+ } catch {
532
+ }
533
+ }
534
+ });
535
+ return () => {
536
+ unsub();
537
+ channel.close();
538
+ };
539
+ }
540
+ function setupStorageFallback(store, name) {
541
+ const storageKey = `starfish-broadcast-${name}`;
542
+ let lastReceivedData = null;
543
+ const onStorage = (e) => {
544
+ if (e.key !== storageKey || !e.newValue) return;
545
+ let payload;
546
+ try {
547
+ payload = JSON.parse(e.newValue);
548
+ } catch {
549
+ return;
550
+ }
551
+ if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object") return;
552
+ lastReceivedData = payload.data;
553
+ store.setState({ data: payload.data, dirty: !!payload.dirty });
554
+ };
555
+ globalThis.addEventListener("storage", onStorage);
556
+ const unsub = store.subscribe((state, prev) => {
557
+ if (state.data === lastReceivedData) return;
558
+ if (state.data !== prev.data || state.dirty !== prev.dirty) {
559
+ try {
560
+ localStorage.setItem(
561
+ storageKey,
562
+ JSON.stringify({ data: state.data, dirty: state.dirty })
563
+ );
564
+ } catch {
565
+ }
566
+ }
567
+ });
568
+ return () => {
569
+ unsub();
570
+ globalThis.removeEventListener("storage", onStorage);
571
+ };
572
+ }
573
+ function setupCrossTabSync(store, name) {
574
+ if (typeof BroadcastChannel !== "undefined") {
575
+ return setupBroadcastSync(store, name);
576
+ }
577
+ if (typeof globalThis.addEventListener === "function" && typeof localStorage !== "undefined") {
578
+ return setupStorageFallback(store, name);
579
+ }
580
+ return () => {
581
+ };
582
+ }
583
+
584
+ // src/bindings/zustand.ts
585
+ function createStarfishStore(options) {
586
+ const { name, syncManager, storage } = options;
587
+ const storeCreator = (rawSet, get) => {
588
+ const set = rawSet;
589
+ return {
590
+ data: {},
591
+ syncing: false,
592
+ online: true,
593
+ dirty: false,
594
+ error: null,
595
+ pull: async () => {
596
+ set({ syncing: true, error: null }, false, "pull/start");
597
+ try {
598
+ await syncManager.pull();
599
+ const newData = syncManager.getData();
600
+ set({ data: newData, syncing: false }, false, "pull/success");
601
+ options.onRemoteUpdate?.(newData);
602
+ } catch (err) {
603
+ set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
604
+ }
605
+ },
606
+ set: (modifier) => {
607
+ try {
608
+ const next = options.produce ? options.produce(get().data, modifier) : modifier(get().data);
609
+ set({ data: next, dirty: true, error: null }, false, "set");
610
+ if (get().online) get().flush().catch(() => {
611
+ });
612
+ } catch (err) {
613
+ set({ error: err instanceof Error ? err.message : String(err) }, false, "set/error");
614
+ }
615
+ },
616
+ restore: (data) => {
617
+ set({ data }, false, "restore");
618
+ },
619
+ flush: async () => {
620
+ if (get().syncing || !get().dirty) return;
621
+ set({ syncing: true, error: null }, false, "flush/start");
622
+ try {
623
+ await syncManager.push(get().data);
624
+ set({ data: syncManager.getData(), syncing: false, dirty: false }, false, "flush/success");
625
+ } catch (err) {
626
+ set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
627
+ }
628
+ },
629
+ setOnline: (online) => {
630
+ set({ online }, false, "setOnline");
631
+ if (online && get().dirty) get().flush().catch(() => {
76
632
  });
77
- const withSelector = subscribeWithSelector(withPersist);
78
- return createStore()(options.devtools ? options.devtools(withSelector) : withSelector);
633
+ }
634
+ };
635
+ };
636
+ const withPersist = storage === false ? storeCreator : persist(storeCreator, {
637
+ name: `starfish-${name}`,
638
+ storage: storage ? createJSONStorage(() => storage) : void 0,
639
+ partialize: (state) => ({
640
+ data: state.data,
641
+ dirty: state.dirty
642
+ })
643
+ });
644
+ const withSelector = subscribeWithSelector(withPersist);
645
+ return createStore()(
646
+ options.devtools ? options.devtools(withSelector) : withSelector
647
+ );
79
648
  }
80
- /** Derive a single sync status from store state. */
81
- export function deriveSyncStatus(state) {
82
- if (!state.online)
83
- return "offline";
84
- if (state.error)
85
- return "error";
86
- if (state.syncing)
87
- return "syncing";
88
- if (state.dirty)
89
- return "pending";
90
- return "synced";
649
+ function deriveSyncStatus(state) {
650
+ if (!state.online) return "offline";
651
+ if (state.error) return "error";
652
+ if (state.syncing) return "syncing";
653
+ if (state.dirty) return "pending";
654
+ return "synced";
91
655
  }
92
- /**
93
- * Aggregate multiple sync statuses into a single worst-case status.
94
- * Priority (worst first): error > syncing > pending > offline > synced.
95
- */
96
- export function aggregateSyncStatus(statuses) {
97
- if (statuses.includes("error"))
98
- return "error";
99
- if (statuses.includes("syncing"))
100
- return "syncing";
101
- if (statuses.includes("pending"))
102
- return "pending";
103
- if (statuses.includes("offline"))
104
- return "offline";
105
- return "synced";
656
+ function aggregateSyncStatus(statuses) {
657
+ if (statuses.includes("error")) return "error";
658
+ if (statuses.includes("syncing")) return "syncing";
659
+ if (statuses.includes("pending")) return "pending";
660
+ if (statuses.includes("offline")) return "offline";
661
+ return "synced";
106
662
  }
107
- /** Use the full Starfish store state and actions. */
108
- export function useStarfish(store) {
109
- return useStore(store);
663
+ function useStarfish(store) {
664
+ return useStore(store);
110
665
  }
111
- /** Use only the synced data, with an optional selector for fine-grained subscriptions. */
112
- export function useStarfishData(store, selector) {
113
- return useStore(store, (state) => selector ? selector(state.data) : state.data);
666
+ function useStarfishData(store, selector) {
667
+ return useStore(
668
+ store,
669
+ (state) => selector ? selector(state.data) : state.data
670
+ );
114
671
  }
115
- /** Use the derived sync status (synced | syncing | pending | error | offline). */
116
- export function useSyncStatus(store) {
117
- return useStore(store, deriveSyncStatus);
672
+ function useSyncStatus(store) {
673
+ return useStore(store, deriveSyncStatus);
118
674
  }
119
- /**
120
- * Subscribe to sync status changes outside of React.
121
- *
122
- * Framework-agnostic — works in React Native, Node.js, or anywhere hooks are unavailable.
123
- * The callback is invoked immediately with the current status and then on every change.
124
- *
125
- * ```ts
126
- * const unsub = subscribeSyncStatus(store, (status) => {
127
- * updateStatusBar(status)
128
- * })
129
- *
130
- * // Later, to stop listening:
131
- * unsub()
132
- * ```
133
- */
134
- export function subscribeSyncStatus(store, callback) {
135
- let prev = deriveSyncStatus(store.getState());
136
- callback(prev);
137
- return store.subscribe((state) => {
138
- const next = deriveSyncStatus(state);
139
- if (next !== prev) {
140
- prev = next;
141
- callback(next);
142
- }
143
- });
675
+ function subscribeSyncStatus(store, callback) {
676
+ let prev = deriveSyncStatus(store.getState());
677
+ callback(prev);
678
+ return store.subscribe((state) => {
679
+ const next = deriveSyncStatus(state);
680
+ if (next !== prev) {
681
+ prev = next;
682
+ callback(next);
683
+ }
684
+ });
144
685
  }
145
- /** Sets up cross-tab sync for a Starfish store. Cleans up on unmount. */
146
- export function useCrossTabSync(store, name) {
147
- useEffect(() => {
148
- return setupCrossTabSync(store, name);
149
- }, [store, name]);
686
+ function useCrossTabSync(store, name) {
687
+ useEffect(() => {
688
+ return setupCrossTabSync(store, name);
689
+ }, [store, name]);
150
690
  }
151
- /** Binds browser online/offline events to the store's setOnline action. Cleans up on unmount. */
152
- export function useConnectivity(store) {
153
- useEffect(() => {
154
- const handleOnline = () => store.getState().setOnline(true);
155
- const handleOffline = () => store.getState().setOnline(false);
156
- window.addEventListener("online", handleOnline);
157
- window.addEventListener("offline", handleOffline);
158
- return () => {
159
- window.removeEventListener("online", handleOnline);
160
- window.removeEventListener("offline", handleOffline);
161
- };
162
- }, [store]);
691
+ function useConnectivity(store) {
692
+ useEffect(() => {
693
+ const handleOnline = () => store.getState().setOnline(true);
694
+ const handleOffline = () => store.getState().setOnline(false);
695
+ window.addEventListener("online", handleOnline);
696
+ window.addEventListener("offline", handleOffline);
697
+ return () => {
698
+ window.removeEventListener("online", handleOnline);
699
+ window.removeEventListener("offline", handleOffline);
700
+ };
701
+ }, [store]);
163
702
  }
164
- /** Returns a human-readable "last synced" label that updates every 5 seconds. */
165
- export function useLastSynced(store) {
166
- const lastSyncedAt = useRef(null);
167
- const [label, setLabel] = useState("Never synced");
168
- const computeLabel = useCallback(() => {
169
- if (lastSyncedAt.current === null)
170
- return "Never synced";
171
- const seconds = Math.floor((Date.now() - lastSyncedAt.current) / 1000);
172
- if (seconds < 10)
173
- return "Just now";
174
- if (seconds < 60)
175
- return `${seconds}s ago`;
176
- return `${Math.floor(seconds / 60)}m ago`;
177
- }, []);
178
- // Track sync completion
179
- useEffect(() => {
180
- let prevSyncing = store.getState().syncing;
181
- const unsub = store.subscribe((state) => {
182
- if (prevSyncing && !state.syncing && !state.error) {
183
- lastSyncedAt.current = Date.now();
184
- setLabel(computeLabel());
185
- }
186
- prevSyncing = state.syncing;
187
- });
188
- return unsub;
189
- }, [store, computeLabel]);
190
- // Update label periodically
191
- useEffect(() => {
192
- const timer = setInterval(() => {
193
- setLabel(computeLabel());
194
- }, 5000);
195
- return () => clearInterval(timer);
196
- }, [computeLabel]);
197
- return label;
703
+ function useLastSynced(store) {
704
+ const lastSyncedAt = useRef(null);
705
+ const [label, setLabel] = useState("Never synced");
706
+ const computeLabel = useCallback(() => {
707
+ if (lastSyncedAt.current === null) return "Never synced";
708
+ const seconds = Math.floor((Date.now() - lastSyncedAt.current) / 1e3);
709
+ if (seconds < 10) return "Just now";
710
+ if (seconds < 60) return `${seconds}s ago`;
711
+ return `${Math.floor(seconds / 60)}m ago`;
712
+ }, []);
713
+ useEffect(() => {
714
+ let prevSyncing = store.getState().syncing;
715
+ const unsub = store.subscribe((state) => {
716
+ if (prevSyncing && !state.syncing && !state.error) {
717
+ lastSyncedAt.current = Date.now();
718
+ setLabel(computeLabel());
719
+ }
720
+ prevSyncing = state.syncing;
721
+ });
722
+ return unsub;
723
+ }, [store, computeLabel]);
724
+ useEffect(() => {
725
+ const timer = setInterval(() => {
726
+ setLabel(computeLabel());
727
+ }, 5e3);
728
+ return () => clearInterval(timer);
729
+ }, [computeLabel]);
730
+ return label;
198
731
  }
199
- /**
200
- * React hook that manages the full Starfish sync lifecycle.
201
- *
202
- * Creates StarfishClient → SyncManager → Zustand store, pulls on mount,
203
- * calls `onData` when remote data arrives, and tears down on unmount or
204
- * config change.
205
- *
206
- * Pass `null` to disable sync (returns `null`).
207
- */
208
- export function useSyncInit(config) {
209
- const [store, setStore] = useState(null);
210
- const onDataRef = useRef(config?.onData);
211
- onDataRef.current = config?.onData;
212
- useEffect(() => {
213
- if (!config) {
214
- setStore(null);
215
- return;
732
+ function useSyncInit(config) {
733
+ const [store, setStore] = useState(null);
734
+ const onDataRef = useRef(config?.onData);
735
+ onDataRef.current = config?.onData;
736
+ useEffect(() => {
737
+ if (!config) {
738
+ setStore(null);
739
+ return;
740
+ }
741
+ const client = new StarfishClient({
742
+ baseUrl: config.serverUrl,
743
+ auth: config.auth,
744
+ fetch: config.fetch
745
+ });
746
+ const syncManager = new SyncManager({
747
+ client,
748
+ pullPath: config.pullPath,
749
+ pushPath: config.pushPath,
750
+ encryptionSecret: config.encryptionSecret,
751
+ encryptionSalt: config.encryptionSalt,
752
+ onConflict: config.onConflict,
753
+ logger: config.logger,
754
+ validate: config.validate
755
+ });
756
+ const newStore = createStarfishStore({
757
+ name: config.storeName ?? "sync",
758
+ syncManager,
759
+ storage: config.storage,
760
+ // onRemoteUpdate fires only for pull() results, never for local set() writes —
761
+ // so no isRestoring flag is needed.
762
+ onRemoteUpdate: (data) => {
763
+ try {
764
+ onDataRef.current?.(data);
765
+ } catch (err) {
766
+ newStore.setState({
767
+ error: `onData failed: ${err instanceof Error ? err.message : String(err)}`
768
+ });
216
769
  }
217
- const client = new StarfishClient({
218
- baseUrl: config.serverUrl,
219
- auth: config.auth,
220
- fetch: config.fetch,
221
- });
222
- const syncManager = new SyncManager({
223
- client,
224
- pullPath: config.pullPath,
225
- pushPath: config.pushPath,
226
- encryptionSecret: config.encryptionSecret,
227
- encryptionSalt: config.encryptionSalt,
228
- onConflict: config.onConflict,
229
- logger: config.logger,
230
- validate: config.validate,
231
- });
232
- const newStore = createStarfishStore({
233
- name: config.storeName ?? "sync",
234
- syncManager,
235
- storage: config.storage,
236
- // onRemoteUpdate fires only for pull() results, never for local set() writes —
237
- // so no isRestoring flag is needed.
238
- onRemoteUpdate: (data) => {
239
- try {
240
- onDataRef.current?.(data);
241
- }
242
- catch (err) {
243
- newStore.setState({
244
- error: `onData failed: ${err instanceof Error ? err.message : String(err)}`,
245
- });
246
- }
247
- },
248
- });
249
- setStore(newStore);
250
- // Initial pull — errors are stored in state.error by the pull() action
251
- newStore.getState().pull().catch(() => { });
252
- return () => {
253
- setStore(null);
254
- };
255
- // Intentionally depend on serializable config values, not the object reference
256
- // eslint-disable-next-line react-hooks/exhaustive-deps
257
- }, [
258
- config?.serverUrl,
259
- config?.pullPath,
260
- config?.pushPath,
261
- config?.encryptionSecret,
262
- config?.encryptionSalt,
263
- config?.storeName,
264
- ]);
265
- return store;
770
+ }
771
+ });
772
+ setStore(newStore);
773
+ newStore.getState().pull().catch(() => {
774
+ });
775
+ return () => {
776
+ setStore(null);
777
+ };
778
+ }, [
779
+ config?.serverUrl,
780
+ config?.pullPath,
781
+ config?.pushPath,
782
+ config?.encryptionSecret,
783
+ config?.encryptionSalt,
784
+ config?.storeName
785
+ ]);
786
+ return store;
266
787
  }
788
+ export {
789
+ aggregateSyncStatus,
790
+ createStarfishStore,
791
+ deriveSyncStatus,
792
+ subscribeSyncStatus,
793
+ useConnectivity,
794
+ useCrossTabSync,
795
+ useLastSynced,
796
+ useStarfish,
797
+ useStarfishData,
798
+ useSyncInit,
799
+ useSyncStatus
800
+ };
801
+ //# sourceMappingURL=zustand.js.map