@drakkar.software/starfish-client 1.18.0 → 1.19.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.
@@ -1,272 +1,581 @@
1
+ // src/bindings/zustand.ts
1
2
  import { createStore } from "zustand/vanilla";
2
3
  import { useStore } from "zustand";
3
- import { persist, devtools, subscribeWithSelector, createJSONStorage, } from "zustand/middleware";
4
+ import {
5
+ persist,
6
+ subscribeWithSelector,
7
+ createJSONStorage
8
+ } from "zustand/middleware";
4
9
  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
- };
66
- };
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
- }),
76
- });
77
- const withSelector = subscribeWithSelector(withPersist);
78
- if (options.devtools) {
79
- const devtoolsOpts = typeof options.devtools === "object"
80
- ? options.devtools
81
- : { name: `starfish-${name}` };
82
- return createStore()(devtools(withSelector, devtoolsOpts));
10
+
11
+ // src/types.ts
12
+ var ConflictError = class extends Error {
13
+ constructor() {
14
+ super("hash_mismatch");
15
+ this.name = "ConflictError";
16
+ }
17
+ };
18
+ var StarfishHttpError = class extends Error {
19
+ constructor(status, body) {
20
+ super(`HTTP ${status}: ${body}`);
21
+ this.status = status;
22
+ this.body = body;
23
+ this.name = "StarfishHttpError";
24
+ }
25
+ };
26
+
27
+ // src/client.ts
28
+ var StarfishClient = class {
29
+ baseUrl;
30
+ auth;
31
+ fetch;
32
+ constructor(options) {
33
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
34
+ this.auth = options.auth;
35
+ this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
36
+ }
37
+ /**
38
+ * Pull synced data from the server.
39
+ * @param path - The pull endpoint path (e.g. "/pull/users/abc/settings")
40
+ * @param checkpoint - Only return data updated after this timestamp (0 = full pull)
41
+ */
42
+ async pull(path, checkpoint) {
43
+ const url = checkpoint ? `${this.baseUrl}${path}?checkpoint=${checkpoint}` : `${this.baseUrl}${path}`;
44
+ const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
45
+ const res = await this.fetch(url, {
46
+ method: "GET",
47
+ headers: { Accept: "application/json", ...authHeaders }
48
+ });
49
+ if (!res.ok) {
50
+ throw new StarfishHttpError(res.status, await res.text());
51
+ }
52
+ return res.json();
53
+ }
54
+ /**
55
+ * Push synced data to the server.
56
+ * @param path - The push endpoint path (e.g. "/push/users/abc/settings")
57
+ * @param data - The full document data to push
58
+ * @param baseHash - Hash of the document this push is based on (null for first push)
59
+ * @param authorSignature - Optional author signature for provenance
60
+ * @throws {ConflictError} if the server detects a hash mismatch (409)
61
+ */
62
+ async push(path, data, baseHash, authorSignature) {
63
+ const body = JSON.stringify({
64
+ data,
65
+ baseHash,
66
+ ...authorSignature && { authorSignature }
67
+ });
68
+ const authHeaders = this.auth ? await this.auth({ method: "POST", path, body }) : {};
69
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
70
+ method: "POST",
71
+ headers: {
72
+ "Content-Type": "application/json",
73
+ Accept: "application/json",
74
+ ...authHeaders
75
+ },
76
+ body
77
+ });
78
+ if (res.status === 409) {
79
+ throw new ConflictError();
80
+ }
81
+ if (!res.ok) {
82
+ throw new StarfishHttpError(res.status, await res.text());
83
+ }
84
+ return res.json();
85
+ }
86
+ /**
87
+ * Pull binary data from a blob collection.
88
+ * Returns raw bytes with the content hash from the ETag header.
89
+ */
90
+ async pullBlob(path) {
91
+ const authHeaders = this.auth ? await this.auth({ method: "GET", path, body: null }) : {};
92
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
93
+ method: "GET",
94
+ headers: { Accept: "*/*", ...authHeaders }
95
+ });
96
+ if (!res.ok) {
97
+ throw new StarfishHttpError(res.status, await res.text());
98
+ }
99
+ const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
100
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
101
+ const data = await res.arrayBuffer();
102
+ return { data, hash: etag, contentType };
103
+ }
104
+ /**
105
+ * Push binary data to a blob collection.
106
+ * Binary collections use last-write-wins (no conflict detection).
107
+ */
108
+ async pushBlob(path, data, contentType) {
109
+ const authHeaders = this.auth ? await this.auth({ method: "POST", path, body: null }) : {};
110
+ const res = await this.fetch(`${this.baseUrl}${path}`, {
111
+ method: "POST",
112
+ headers: {
113
+ "Content-Type": contentType,
114
+ Accept: "application/json",
115
+ ...authHeaders
116
+ },
117
+ body: data
118
+ });
119
+ if (!res.ok) {
120
+ throw new StarfishHttpError(res.status, await res.text());
121
+ }
122
+ return res.json();
123
+ }
124
+ };
125
+
126
+ // src/sync.ts
127
+ import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
128
+
129
+ // src/crypto.ts
130
+ import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
131
+ var ALGO = "AES-GCM";
132
+ function createEncryptor(secret, salt, info = "starfish-e2e") {
133
+ if (!secret) throw new Error("encryptionSecret must not be empty");
134
+ if (!salt) throw new Error("encryptionSalt must not be empty");
135
+ const keyPromise = deriveKey(secret, salt, info);
136
+ return {
137
+ async encrypt(data) {
138
+ const key = await keyPromise;
139
+ const c = getCrypto();
140
+ const b64 = getBase64();
141
+ const plaintext = new TextEncoder().encode(JSON.stringify(data));
142
+ const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
143
+ const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
144
+ const combined = new Uint8Array(iv.length + ciphertext.byteLength);
145
+ combined.set(iv);
146
+ combined.set(new Uint8Array(ciphertext), iv.length);
147
+ return { [ENCRYPTED_KEY]: b64.encode(combined) };
148
+ },
149
+ async decrypt(wrapper) {
150
+ const encoded = wrapper[ENCRYPTED_KEY];
151
+ if (typeof encoded !== "string") {
152
+ throw new Error("Expected encrypted data but received unencrypted document");
153
+ }
154
+ const key = await keyPromise;
155
+ const c = getCrypto();
156
+ const b64 = getBase64();
157
+ const combined = b64.decode(encoded);
158
+ if (combined.length < IV_BYTES) {
159
+ throw new Error("Encrypted data is too short");
160
+ }
161
+ const iv = combined.slice(0, IV_BYTES);
162
+ const ciphertext = combined.slice(IV_BYTES);
163
+ try {
164
+ const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
165
+ return JSON.parse(new TextDecoder().decode(plaintext));
166
+ } catch (err) {
167
+ throw new Error("Decryption failed: data may be tampered or key is incorrect", { cause: err });
168
+ }
169
+ }
170
+ };
171
+ }
172
+
173
+ // src/validate.ts
174
+ var ValidationError = class extends Error {
175
+ constructor(errors) {
176
+ super(`Validation failed: ${errors.join("; ")}`);
177
+ this.errors = errors;
178
+ this.name = "ValidationError";
179
+ }
180
+ };
181
+
182
+ // src/sync.ts
183
+ var SyncManager = class {
184
+ client;
185
+ pullPath;
186
+ pushPath;
187
+ onConflict;
188
+ maxRetries;
189
+ encryptor;
190
+ signData;
191
+ logger;
192
+ loggerName;
193
+ validate;
194
+ lastHash = null;
195
+ lastCheckpoint = 0;
196
+ localData = {};
197
+ constructor(options) {
198
+ this.client = options.client;
199
+ this.pullPath = options.pullPath;
200
+ this.pushPath = options.pushPath;
201
+ this.onConflict = options.onConflict ?? deepMerge;
202
+ this.maxRetries = options.maxRetries ?? 3;
203
+ this.signData = options.signData;
204
+ this.logger = options.logger;
205
+ this.loggerName = options.loggerName ?? options.pullPath.split("/").filter(Boolean).pop() ?? options.pullPath;
206
+ this.validate = options.validate;
207
+ this.encryptor = options.encryptor ?? (options.encryptionSecret && options.encryptionSalt ? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo) : null);
208
+ }
209
+ getData() {
210
+ return { ...this.localData };
211
+ }
212
+ getHash() {
213
+ return this.lastHash;
214
+ }
215
+ getCheckpoint() {
216
+ return this.lastCheckpoint;
217
+ }
218
+ async pull() {
219
+ this.logger?.pullStart(this.loggerName);
220
+ const start = performance.now();
221
+ try {
222
+ const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
223
+ if (this.encryptor) {
224
+ const decrypted = await this.encryptor.decrypt(result.data);
225
+ this.localData = decrypted;
226
+ result.data = decrypted;
227
+ } else if (this.lastCheckpoint > 0) {
228
+ this.localData = deepMerge(this.localData, result.data);
229
+ result.data = this.localData;
230
+ } else {
231
+ this.localData = result.data;
232
+ }
233
+ this.lastHash = result.hash;
234
+ this.lastCheckpoint = result.timestamp;
235
+ this.logger?.pullSuccess(this.loggerName, Math.round(performance.now() - start));
236
+ return result;
237
+ } catch (err) {
238
+ this.logger?.pullError(this.loggerName, err instanceof Error ? err.message : String(err));
239
+ throw err;
240
+ }
241
+ }
242
+ async push(data) {
243
+ if (this.validate) {
244
+ const result = this.validate(data);
245
+ if (result !== true) throw new ValidationError(result);
246
+ }
247
+ this.logger?.pushStart(this.loggerName);
248
+ const start = performance.now();
249
+ let attempt = 0;
250
+ let pendingData = data;
251
+ while (attempt <= this.maxRetries) {
252
+ try {
253
+ const payload = this.encryptor ? await this.encryptor.encrypt(pendingData) : pendingData;
254
+ const sig = this.signData ? await this.signData(stableStringify(payload)) : void 0;
255
+ const result = await this.client.push(
256
+ this.pushPath,
257
+ payload,
258
+ this.lastHash,
259
+ sig
260
+ );
261
+ this.lastHash = result.hash;
262
+ this.lastCheckpoint = result.timestamp;
263
+ this.localData = pendingData;
264
+ this.logger?.pushSuccess(this.loggerName, Math.round(performance.now() - start));
265
+ return result;
266
+ } catch (err) {
267
+ if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
268
+ this.logger?.pushError(this.loggerName, err instanceof Error ? err.message : String(err));
269
+ throw err;
270
+ }
271
+ this.logger?.conflict(this.loggerName, attempt + 1);
272
+ try {
273
+ const remote = await this.client.pull(this.pullPath);
274
+ const remoteData = this.encryptor ? await this.encryptor.decrypt(remote.data) : remote.data;
275
+ this.lastHash = remote.hash;
276
+ this.lastCheckpoint = remote.timestamp;
277
+ pendingData = this.onConflict(pendingData, remoteData);
278
+ } catch (resolveErr) {
279
+ const msg = resolveErr instanceof Error ? resolveErr.message : String(resolveErr);
280
+ this.logger?.pushError(this.loggerName, `Conflict resolution failed (attempt ${attempt + 1}): ${msg}`);
281
+ throw resolveErr;
282
+ }
283
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 2e3) + Math.random() * 100));
284
+ attempt++;
285
+ }
286
+ }
287
+ throw new ConflictError();
288
+ }
289
+ async update(modifier) {
290
+ await this.pull();
291
+ const updated = modifier(this.localData);
292
+ return this.push(updated);
293
+ }
294
+ };
295
+
296
+ // src/broadcast.ts
297
+ function setupBroadcastSync(store, name) {
298
+ const channel = new BroadcastChannel(`starfish-${name}`);
299
+ let lastReceivedData = null;
300
+ channel.onmessage = (event) => {
301
+ const payload = event.data;
302
+ if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object") return;
303
+ lastReceivedData = payload.data;
304
+ store.setState({ data: payload.data, dirty: !!payload.dirty });
305
+ };
306
+ const unsub = store.subscribe((state, prev) => {
307
+ if (state.data === lastReceivedData) return;
308
+ if (state.data !== prev.data || state.dirty !== prev.dirty) {
309
+ try {
310
+ channel.postMessage({ data: state.data, dirty: state.dirty });
311
+ } catch {
312
+ }
313
+ }
314
+ });
315
+ return () => {
316
+ unsub();
317
+ channel.close();
318
+ };
319
+ }
320
+ function setupStorageFallback(store, name) {
321
+ const storageKey = `starfish-broadcast-${name}`;
322
+ let lastReceivedData = null;
323
+ const onStorage = (e) => {
324
+ if (e.key !== storageKey || !e.newValue) return;
325
+ let payload;
326
+ try {
327
+ payload = JSON.parse(e.newValue);
328
+ } catch {
329
+ return;
330
+ }
331
+ if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object") return;
332
+ lastReceivedData = payload.data;
333
+ store.setState({ data: payload.data, dirty: !!payload.dirty });
334
+ };
335
+ globalThis.addEventListener("storage", onStorage);
336
+ const unsub = store.subscribe((state, prev) => {
337
+ if (state.data === lastReceivedData) return;
338
+ if (state.data !== prev.data || state.dirty !== prev.dirty) {
339
+ try {
340
+ localStorage.setItem(
341
+ storageKey,
342
+ JSON.stringify({ data: state.data, dirty: state.dirty })
343
+ );
344
+ } catch {
345
+ }
83
346
  }
84
- return createStore()(withSelector);
347
+ });
348
+ return () => {
349
+ unsub();
350
+ globalThis.removeEventListener("storage", onStorage);
351
+ };
85
352
  }
86
- /** Derive a single sync status from store state. */
87
- export function deriveSyncStatus(state) {
88
- if (!state.online)
89
- return "offline";
90
- if (state.error)
91
- return "error";
92
- if (state.syncing)
93
- return "syncing";
94
- if (state.dirty)
95
- return "pending";
96
- return "synced";
353
+ function setupCrossTabSync(store, name) {
354
+ if (typeof BroadcastChannel !== "undefined") {
355
+ return setupBroadcastSync(store, name);
356
+ }
357
+ if (typeof globalThis.addEventListener === "function" && typeof localStorage !== "undefined") {
358
+ return setupStorageFallback(store, name);
359
+ }
360
+ return () => {
361
+ };
97
362
  }
98
- /**
99
- * Aggregate multiple sync statuses into a single worst-case status.
100
- * Priority (worst first): error > syncing > pending > offline > synced.
101
- */
102
- export function aggregateSyncStatus(statuses) {
103
- if (statuses.includes("error"))
104
- return "error";
105
- if (statuses.includes("syncing"))
106
- return "syncing";
107
- if (statuses.includes("pending"))
108
- return "pending";
109
- if (statuses.includes("offline"))
110
- return "offline";
111
- return "synced";
363
+
364
+ // src/bindings/zustand.ts
365
+ function createStarfishStore(options) {
366
+ const { name, syncManager, storage } = options;
367
+ const storeCreator = (rawSet, get) => {
368
+ const set = rawSet;
369
+ return {
370
+ data: {},
371
+ syncing: false,
372
+ online: true,
373
+ dirty: false,
374
+ error: null,
375
+ pull: async () => {
376
+ set({ syncing: true, error: null }, false, "pull/start");
377
+ try {
378
+ await syncManager.pull();
379
+ const newData = syncManager.getData();
380
+ set({ data: newData, syncing: false }, false, "pull/success");
381
+ options.onRemoteUpdate?.(newData);
382
+ } catch (err) {
383
+ set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
384
+ }
385
+ },
386
+ set: (modifier) => {
387
+ try {
388
+ const next = options.produce ? options.produce(get().data, modifier) : modifier(get().data);
389
+ set({ data: next, dirty: true, error: null }, false, "set");
390
+ if (get().online) get().flush().catch(() => {
391
+ });
392
+ } catch (err) {
393
+ set({ error: err instanceof Error ? err.message : String(err) }, false, "set/error");
394
+ }
395
+ },
396
+ restore: (data) => {
397
+ set({ data }, false, "restore");
398
+ },
399
+ flush: async () => {
400
+ if (get().syncing || !get().dirty) return;
401
+ set({ syncing: true, error: null }, false, "flush/start");
402
+ try {
403
+ await syncManager.push(get().data);
404
+ set({ data: syncManager.getData(), syncing: false, dirty: false }, false, "flush/success");
405
+ } catch (err) {
406
+ set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
407
+ }
408
+ },
409
+ setOnline: (online) => {
410
+ set({ online }, false, "setOnline");
411
+ if (online && get().dirty) get().flush().catch(() => {
412
+ });
413
+ }
414
+ };
415
+ };
416
+ const withPersist = storage === false ? storeCreator : persist(storeCreator, {
417
+ name: `starfish-${name}`,
418
+ storage: storage ? createJSONStorage(() => storage) : void 0,
419
+ partialize: (state) => ({
420
+ data: state.data,
421
+ dirty: state.dirty
422
+ })
423
+ });
424
+ const withSelector = subscribeWithSelector(withPersist);
425
+ return createStore()(
426
+ options.devtools ? options.devtools(withSelector) : withSelector
427
+ );
112
428
  }
113
- /** Use the full Starfish store state and actions. */
114
- export function useStarfish(store) {
115
- return useStore(store);
429
+ function deriveSyncStatus(state) {
430
+ if (!state.online) return "offline";
431
+ if (state.error) return "error";
432
+ if (state.syncing) return "syncing";
433
+ if (state.dirty) return "pending";
434
+ return "synced";
116
435
  }
117
- /** Use only the synced data, with an optional selector for fine-grained subscriptions. */
118
- export function useStarfishData(store, selector) {
119
- return useStore(store, (state) => selector ? selector(state.data) : state.data);
436
+ function aggregateSyncStatus(statuses) {
437
+ if (statuses.includes("error")) return "error";
438
+ if (statuses.includes("syncing")) return "syncing";
439
+ if (statuses.includes("pending")) return "pending";
440
+ if (statuses.includes("offline")) return "offline";
441
+ return "synced";
120
442
  }
121
- /** Use the derived sync status (synced | syncing | pending | error | offline). */
122
- export function useSyncStatus(store) {
123
- return useStore(store, deriveSyncStatus);
443
+ function useStarfish(store) {
444
+ return useStore(store);
124
445
  }
125
- /**
126
- * Subscribe to sync status changes outside of React.
127
- *
128
- * Framework-agnostic works in React Native, Node.js, or anywhere hooks are unavailable.
129
- * The callback is invoked immediately with the current status and then on every change.
130
- *
131
- * ```ts
132
- * const unsub = subscribeSyncStatus(store, (status) => {
133
- * updateStatusBar(status)
134
- * })
135
- *
136
- * // Later, to stop listening:
137
- * unsub()
138
- * ```
139
- */
140
- export function subscribeSyncStatus(store, callback) {
141
- let prev = deriveSyncStatus(store.getState());
142
- callback(prev);
143
- return store.subscribe((state) => {
144
- const next = deriveSyncStatus(state);
145
- if (next !== prev) {
146
- prev = next;
147
- callback(next);
148
- }
149
- });
446
+ function useStarfishData(store, selector) {
447
+ return useStore(
448
+ store,
449
+ (state) => selector ? selector(state.data) : state.data
450
+ );
150
451
  }
151
- /** Sets up cross-tab sync for a Starfish store. Cleans up on unmount. */
152
- export function useCrossTabSync(store, name) {
153
- useEffect(() => {
154
- return setupCrossTabSync(store, name);
155
- }, [store, name]);
452
+ function useSyncStatus(store) {
453
+ return useStore(store, deriveSyncStatus);
156
454
  }
157
- /** Binds browser online/offline events to the store's setOnline action. Cleans up on unmount. */
158
- export function useConnectivity(store) {
159
- useEffect(() => {
160
- const handleOnline = () => store.getState().setOnline(true);
161
- const handleOffline = () => store.getState().setOnline(false);
162
- window.addEventListener("online", handleOnline);
163
- window.addEventListener("offline", handleOffline);
164
- return () => {
165
- window.removeEventListener("online", handleOnline);
166
- window.removeEventListener("offline", handleOffline);
167
- };
168
- }, [store]);
455
+ function subscribeSyncStatus(store, callback) {
456
+ let prev = deriveSyncStatus(store.getState());
457
+ callback(prev);
458
+ return store.subscribe((state) => {
459
+ const next = deriveSyncStatus(state);
460
+ if (next !== prev) {
461
+ prev = next;
462
+ callback(next);
463
+ }
464
+ });
169
465
  }
170
- /** Returns a human-readable "last synced" label that updates every 5 seconds. */
171
- export function useLastSynced(store) {
172
- const lastSyncedAt = useRef(null);
173
- const [label, setLabel] = useState("Never synced");
174
- const computeLabel = useCallback(() => {
175
- if (lastSyncedAt.current === null)
176
- return "Never synced";
177
- const seconds = Math.floor((Date.now() - lastSyncedAt.current) / 1000);
178
- if (seconds < 10)
179
- return "Just now";
180
- if (seconds < 60)
181
- return `${seconds}s ago`;
182
- return `${Math.floor(seconds / 60)}m ago`;
183
- }, []);
184
- // Track sync completion
185
- useEffect(() => {
186
- let prevSyncing = store.getState().syncing;
187
- const unsub = store.subscribe((state) => {
188
- if (prevSyncing && !state.syncing && !state.error) {
189
- lastSyncedAt.current = Date.now();
190
- setLabel(computeLabel());
191
- }
192
- prevSyncing = state.syncing;
193
- });
194
- return unsub;
195
- }, [store, computeLabel]);
196
- // Update label periodically
197
- useEffect(() => {
198
- const timer = setInterval(() => {
199
- setLabel(computeLabel());
200
- }, 5000);
201
- return () => clearInterval(timer);
202
- }, [computeLabel]);
203
- return label;
466
+ function useCrossTabSync(store, name) {
467
+ useEffect(() => {
468
+ return setupCrossTabSync(store, name);
469
+ }, [store, name]);
470
+ }
471
+ function useConnectivity(store) {
472
+ useEffect(() => {
473
+ const handleOnline = () => store.getState().setOnline(true);
474
+ const handleOffline = () => store.getState().setOnline(false);
475
+ window.addEventListener("online", handleOnline);
476
+ window.addEventListener("offline", handleOffline);
477
+ return () => {
478
+ window.removeEventListener("online", handleOnline);
479
+ window.removeEventListener("offline", handleOffline);
480
+ };
481
+ }, [store]);
204
482
  }
205
- /**
206
- * React hook that manages the full Starfish sync lifecycle.
207
- *
208
- * Creates StarfishClient SyncManager → Zustand store, pulls on mount,
209
- * calls `onData` when remote data arrives, and tears down on unmount or
210
- * config change.
211
- *
212
- * Pass `null` to disable sync (returns `null`).
213
- */
214
- export function useSyncInit(config) {
215
- const [store, setStore] = useState(null);
216
- const onDataRef = useRef(config?.onData);
217
- onDataRef.current = config?.onData;
218
- useEffect(() => {
219
- if (!config) {
220
- setStore(null);
221
- return;
483
+ function useLastSynced(store) {
484
+ const lastSyncedAt = useRef(null);
485
+ const [label, setLabel] = useState("Never synced");
486
+ const computeLabel = useCallback(() => {
487
+ if (lastSyncedAt.current === null) return "Never synced";
488
+ const seconds = Math.floor((Date.now() - lastSyncedAt.current) / 1e3);
489
+ if (seconds < 10) return "Just now";
490
+ if (seconds < 60) return `${seconds}s ago`;
491
+ return `${Math.floor(seconds / 60)}m ago`;
492
+ }, []);
493
+ useEffect(() => {
494
+ let prevSyncing = store.getState().syncing;
495
+ const unsub = store.subscribe((state) => {
496
+ if (prevSyncing && !state.syncing && !state.error) {
497
+ lastSyncedAt.current = Date.now();
498
+ setLabel(computeLabel());
499
+ }
500
+ prevSyncing = state.syncing;
501
+ });
502
+ return unsub;
503
+ }, [store, computeLabel]);
504
+ useEffect(() => {
505
+ const timer = setInterval(() => {
506
+ setLabel(computeLabel());
507
+ }, 5e3);
508
+ return () => clearInterval(timer);
509
+ }, [computeLabel]);
510
+ return label;
511
+ }
512
+ function useSyncInit(config) {
513
+ const [store, setStore] = useState(null);
514
+ const onDataRef = useRef(config?.onData);
515
+ onDataRef.current = config?.onData;
516
+ useEffect(() => {
517
+ if (!config) {
518
+ setStore(null);
519
+ return;
520
+ }
521
+ const client = new StarfishClient({
522
+ baseUrl: config.serverUrl,
523
+ auth: config.auth,
524
+ fetch: config.fetch
525
+ });
526
+ const syncManager = new SyncManager({
527
+ client,
528
+ pullPath: config.pullPath,
529
+ pushPath: config.pushPath,
530
+ encryptionSecret: config.encryptionSecret,
531
+ encryptionSalt: config.encryptionSalt,
532
+ onConflict: config.onConflict,
533
+ logger: config.logger,
534
+ validate: config.validate
535
+ });
536
+ const newStore = createStarfishStore({
537
+ name: config.storeName ?? "sync",
538
+ syncManager,
539
+ storage: config.storage,
540
+ // onRemoteUpdate fires only for pull() results, never for local set() writes —
541
+ // so no isRestoring flag is needed.
542
+ onRemoteUpdate: (data) => {
543
+ try {
544
+ onDataRef.current?.(data);
545
+ } catch (err) {
546
+ newStore.setState({
547
+ error: `onData failed: ${err instanceof Error ? err.message : String(err)}`
548
+ });
222
549
  }
223
- const client = new StarfishClient({
224
- baseUrl: config.serverUrl,
225
- auth: config.auth,
226
- fetch: config.fetch,
227
- });
228
- const syncManager = new SyncManager({
229
- client,
230
- pullPath: config.pullPath,
231
- pushPath: config.pushPath,
232
- encryptionSecret: config.encryptionSecret,
233
- encryptionSalt: config.encryptionSalt,
234
- onConflict: config.onConflict,
235
- logger: config.logger,
236
- validate: config.validate,
237
- });
238
- const newStore = createStarfishStore({
239
- name: config.storeName ?? "sync",
240
- syncManager,
241
- storage: config.storage,
242
- // onRemoteUpdate fires only for pull() results, never for local set() writes —
243
- // so no isRestoring flag is needed.
244
- onRemoteUpdate: (data) => {
245
- try {
246
- onDataRef.current?.(data);
247
- }
248
- catch (err) {
249
- newStore.setState({
250
- error: `onData failed: ${err instanceof Error ? err.message : String(err)}`,
251
- });
252
- }
253
- },
254
- });
255
- setStore(newStore);
256
- // Initial pull — errors are stored in state.error by the pull() action
257
- newStore.getState().pull().catch(() => { });
258
- return () => {
259
- setStore(null);
260
- };
261
- // Intentionally depend on serializable config values, not the object reference
262
- // eslint-disable-next-line react-hooks/exhaustive-deps
263
- }, [
264
- config?.serverUrl,
265
- config?.pullPath,
266
- config?.pushPath,
267
- config?.encryptionSecret,
268
- config?.encryptionSalt,
269
- config?.storeName,
270
- ]);
271
- return store;
550
+ }
551
+ });
552
+ setStore(newStore);
553
+ newStore.getState().pull().catch(() => {
554
+ });
555
+ return () => {
556
+ setStore(null);
557
+ };
558
+ }, [
559
+ config?.serverUrl,
560
+ config?.pullPath,
561
+ config?.pushPath,
562
+ config?.encryptionSecret,
563
+ config?.encryptionSalt,
564
+ config?.storeName
565
+ ]);
566
+ return store;
272
567
  }
568
+ export {
569
+ aggregateSyncStatus,
570
+ createStarfishStore,
571
+ deriveSyncStatus,
572
+ subscribeSyncStatus,
573
+ useConnectivity,
574
+ useCrossTabSync,
575
+ useLastSynced,
576
+ useStarfish,
577
+ useStarfishData,
578
+ useSyncInit,
579
+ useSyncStatus
580
+ };
581
+ //# sourceMappingURL=zustand.js.map