@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.
- package/dist/bindings/legend.js +60 -60
- package/dist/bindings/legend.js.map +7 -0
- package/dist/bindings/zustand.d.ts +12 -3
- package/dist/bindings/zustand.js +565 -256
- package/dist/bindings/zustand.js.map +7 -0
- package/dist/broadcast.js +69 -79
- package/dist/broadcast.js.map +7 -0
- package/dist/fetch.js +131 -156
- package/dist/fetch.js.map +7 -0
- package/dist/group-crypto.js +188 -213
- package/dist/group-crypto.js.map +7 -0
- package/dist/identity.js +346 -154
- package/dist/identity.js.map +7 -0
- package/dist/index.js +1344 -25
- package/dist/index.js.map +7 -0
- package/dist/testing.js +64 -83
- package/dist/testing.js.map +7 -0
- package/package.json +4 -3
package/dist/bindings/zustand.js
CHANGED
|
@@ -1,272 +1,581 @@
|
|
|
1
|
+
// src/bindings/zustand.ts
|
|
1
2
|
import { createStore } from "zustand/vanilla";
|
|
2
3
|
import { useStore } from "zustand";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
persist,
|
|
6
|
+
subscribeWithSelector,
|
|
7
|
+
createJSONStorage
|
|
8
|
+
} from "zustand/middleware";
|
|
4
9
|
import { useEffect, useRef, useState, useCallback } from "react";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
347
|
+
});
|
|
348
|
+
return () => {
|
|
349
|
+
unsub();
|
|
350
|
+
globalThis.removeEventListener("storage", onStorage);
|
|
351
|
+
};
|
|
85
352
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
return useStore(store, deriveSyncStatus);
|
|
443
|
+
function useStarfish(store) {
|
|
444
|
+
return useStore(store);
|
|
124
445
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
useEffect(() => {
|
|
154
|
-
return setupCrossTabSync(store, name);
|
|
155
|
-
}, [store, name]);
|
|
452
|
+
function useSyncStatus(store) {
|
|
453
|
+
return useStore(store, deriveSyncStatus);
|
|
156
454
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|