@drakkar.software/starfish-client 1.3.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.d.ts +25 -0
- package/dist/bindings/legend.js +63 -0
- package/dist/bindings/zustand.d.ts +30 -0
- package/dist/bindings/zustand.js +73 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +70 -0
- package/dist/crypto.d.ts +11 -0
- package/dist/crypto.js +49 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/sync.d.ts +41 -0
- package/dist/sync.js +92 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +18 -0
- package/package.json +65 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Observable } from "@legendapp/state";
|
|
2
|
+
import type { SyncManager } from "../sync.js";
|
|
3
|
+
export interface StarfishLegendState {
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
syncing: boolean;
|
|
6
|
+
online: boolean;
|
|
7
|
+
dirty: boolean;
|
|
8
|
+
error: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface StarfishLegendStore {
|
|
11
|
+
/** The observable state tree — read fields with `.get()` inside `observer` components. */
|
|
12
|
+
state: Observable<StarfishLegendState>;
|
|
13
|
+
pull: () => Promise<void>;
|
|
14
|
+
set: (modifier: (current: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
15
|
+
flush: () => Promise<void>;
|
|
16
|
+
setOnline: (online: boolean) => void;
|
|
17
|
+
}
|
|
18
|
+
export interface CreateStarfishObservableOptions {
|
|
19
|
+
/** Unique name for this collection (used for persistence keys when applicable). */
|
|
20
|
+
name: string;
|
|
21
|
+
syncManager: SyncManager;
|
|
22
|
+
/** Pass `produce` from `immer` to enable draft-based mutations in `set()`. */
|
|
23
|
+
produce?: <T>(base: T, recipe: (draft: T) => T | void) => T;
|
|
24
|
+
}
|
|
25
|
+
export declare function createStarfishObservable(options: CreateStarfishObservableOptions): StarfishLegendStore;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { observable } from "@legendapp/state";
|
|
2
|
+
export function createStarfishObservable(options) {
|
|
3
|
+
const state = observable({
|
|
4
|
+
data: {},
|
|
5
|
+
syncing: false,
|
|
6
|
+
online: true,
|
|
7
|
+
dirty: false,
|
|
8
|
+
error: null,
|
|
9
|
+
});
|
|
10
|
+
const flush = async () => {
|
|
11
|
+
if (state.syncing.get() || !state.dirty.get())
|
|
12
|
+
return;
|
|
13
|
+
state.syncing.set(true);
|
|
14
|
+
state.error.set(null);
|
|
15
|
+
try {
|
|
16
|
+
await options.syncManager.push(state.data.get());
|
|
17
|
+
state.data.set(options.syncManager.getData());
|
|
18
|
+
state.dirty.set(false);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
state.error.set(err.message);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
state.syncing.set(false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const pull = async () => {
|
|
28
|
+
state.syncing.set(true);
|
|
29
|
+
state.error.set(null);
|
|
30
|
+
try {
|
|
31
|
+
await options.syncManager.pull();
|
|
32
|
+
state.data.set(options.syncManager.getData());
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
state.error.set(err.message);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
state.syncing.set(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const set = (modifier) => {
|
|
42
|
+
try {
|
|
43
|
+
const current = state.data.get();
|
|
44
|
+
const next = options.produce
|
|
45
|
+
? options.produce(current, modifier)
|
|
46
|
+
: modifier(current);
|
|
47
|
+
state.data.set(next);
|
|
48
|
+
state.dirty.set(true);
|
|
49
|
+
state.error.set(null);
|
|
50
|
+
if (state.online.get())
|
|
51
|
+
flush();
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
state.error.set(err.message);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const setOnline = (online) => {
|
|
58
|
+
state.online.set(online);
|
|
59
|
+
if (online && state.dirty.get())
|
|
60
|
+
flush();
|
|
61
|
+
};
|
|
62
|
+
return { state, pull, set, flush, setOnline };
|
|
63
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type StoreApi } from "zustand/vanilla";
|
|
2
|
+
import { type StateStorage, type DevtoolsOptions } from "zustand/middleware";
|
|
3
|
+
import type { SyncManager } from "../sync.js";
|
|
4
|
+
export interface StarfishState {
|
|
5
|
+
data: Record<string, unknown>;
|
|
6
|
+
syncing: boolean;
|
|
7
|
+
online: boolean;
|
|
8
|
+
dirty: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface StarfishActions {
|
|
12
|
+
pull: () => Promise<void>;
|
|
13
|
+
set: (modifier: (current: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
14
|
+
flush: () => Promise<void>;
|
|
15
|
+
setOnline: (online: boolean) => void;
|
|
16
|
+
}
|
|
17
|
+
export type StarfishStore = StarfishState & StarfishActions;
|
|
18
|
+
export interface CreateStarfishStoreOptions {
|
|
19
|
+
/** Unique name used as the persistence key (prefixed with `starfish-`) */
|
|
20
|
+
name: string;
|
|
21
|
+
syncManager: SyncManager;
|
|
22
|
+
/** Pass `false` to disable persistence. Defaults to `localStorage` in browsers. */
|
|
23
|
+
storage?: StateStorage | false;
|
|
24
|
+
/** Enable Redux DevTools. Pass `true` or a `DevtoolsOptions` object. */
|
|
25
|
+
devtools?: boolean | DevtoolsOptions;
|
|
26
|
+
/** Pass `produce` from `immer` to enable draft-based mutations in `set()`. */
|
|
27
|
+
produce?: <T>(base: T, recipe: (draft: T) => T | void) => T;
|
|
28
|
+
}
|
|
29
|
+
export type { DevtoolsOptions };
|
|
30
|
+
export declare function createStarfishStore(options: CreateStarfishStoreOptions): StoreApi<StarfishStore>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
import { persist, devtools, subscribeWithSelector, createJSONStorage, } from "zustand/middleware";
|
|
3
|
+
export function createStarfishStore(options) {
|
|
4
|
+
const { name, syncManager, storage } = options;
|
|
5
|
+
const storeCreator = (rawSet, get) => {
|
|
6
|
+
const set = rawSet;
|
|
7
|
+
return {
|
|
8
|
+
data: {},
|
|
9
|
+
syncing: false,
|
|
10
|
+
online: true,
|
|
11
|
+
dirty: false,
|
|
12
|
+
error: null,
|
|
13
|
+
pull: async () => {
|
|
14
|
+
set({ syncing: true, error: null }, false, "pull/start");
|
|
15
|
+
try {
|
|
16
|
+
await syncManager.pull();
|
|
17
|
+
set({ data: syncManager.getData(), syncing: false }, false, "pull/success");
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
set({ syncing: false, error: err.message }, false, "pull/error");
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
set: (modifier) => {
|
|
24
|
+
try {
|
|
25
|
+
const next = options.produce
|
|
26
|
+
? options.produce(get().data, modifier)
|
|
27
|
+
: modifier(get().data);
|
|
28
|
+
set({ data: next, dirty: true, error: null }, false, "set");
|
|
29
|
+
if (get().online)
|
|
30
|
+
get().flush();
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
set({ error: err.message }, false, "set/error");
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
flush: async () => {
|
|
37
|
+
if (get().syncing || !get().dirty)
|
|
38
|
+
return;
|
|
39
|
+
set({ syncing: true, error: null }, false, "flush/start");
|
|
40
|
+
try {
|
|
41
|
+
await syncManager.push(get().data);
|
|
42
|
+
set({ data: syncManager.getData(), syncing: false, dirty: false }, false, "flush/success");
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
set({ syncing: false, error: err.message }, false, "flush/error");
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
setOnline: (online) => {
|
|
49
|
+
set({ online }, false, "setOnline");
|
|
50
|
+
if (online && get().dirty)
|
|
51
|
+
get().flush();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
const withPersist = storage === false
|
|
56
|
+
? storeCreator
|
|
57
|
+
: persist(storeCreator, {
|
|
58
|
+
name: `starfish-${name}`,
|
|
59
|
+
storage: storage ? createJSONStorage(() => storage) : undefined,
|
|
60
|
+
partialize: (state) => ({
|
|
61
|
+
data: state.data,
|
|
62
|
+
dirty: state.dirty,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
const withSelector = subscribeWithSelector(withPersist);
|
|
66
|
+
if (options.devtools) {
|
|
67
|
+
const devtoolsOpts = typeof options.devtools === "object"
|
|
68
|
+
? options.devtools
|
|
69
|
+
: { name: `starfish-${name}` };
|
|
70
|
+
return createStore()(devtools(withSelector, devtoolsOpts));
|
|
71
|
+
}
|
|
72
|
+
return createStore()(withSelector);
|
|
73
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
import type { StarfishClientOptions } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Low-level HTTP client for the Starfish sync protocol.
|
|
5
|
+
* Handles auth headers and response parsing.
|
|
6
|
+
*/
|
|
7
|
+
export declare class StarfishClient {
|
|
8
|
+
private readonly baseUrl;
|
|
9
|
+
private readonly auth?;
|
|
10
|
+
private readonly fetch;
|
|
11
|
+
constructor(options: StarfishClientOptions);
|
|
12
|
+
/**
|
|
13
|
+
* Pull synced data from the server.
|
|
14
|
+
* @param path - The pull endpoint path (e.g. "/pull/users/abc/settings")
|
|
15
|
+
* @param checkpoint - Only return data updated after this timestamp (0 = full pull)
|
|
16
|
+
*/
|
|
17
|
+
pull(path: string, checkpoint?: number): Promise<PullResult>;
|
|
18
|
+
/**
|
|
19
|
+
* Push synced data to the server.
|
|
20
|
+
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
21
|
+
* @param data - The full document data to push
|
|
22
|
+
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
23
|
+
* @param authorSignature - Optional author signature for provenance
|
|
24
|
+
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
25
|
+
*/
|
|
26
|
+
push(path: string, data: Record<string, unknown>, baseHash: string | null, authorSignature?: string): Promise<PushSuccess>;
|
|
27
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ConflictError, StarfishHttpError } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Low-level HTTP client for the Starfish sync protocol.
|
|
4
|
+
* Handles auth headers and response parsing.
|
|
5
|
+
*/
|
|
6
|
+
export class StarfishClient {
|
|
7
|
+
baseUrl;
|
|
8
|
+
auth;
|
|
9
|
+
fetch;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
12
|
+
this.auth = options.auth;
|
|
13
|
+
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Pull synced data from the server.
|
|
17
|
+
* @param path - The pull endpoint path (e.g. "/pull/users/abc/settings")
|
|
18
|
+
* @param checkpoint - Only return data updated after this timestamp (0 = full pull)
|
|
19
|
+
*/
|
|
20
|
+
async pull(path, checkpoint) {
|
|
21
|
+
const url = checkpoint
|
|
22
|
+
? `${this.baseUrl}${path}?checkpoint=${checkpoint}`
|
|
23
|
+
: `${this.baseUrl}${path}`;
|
|
24
|
+
const authHeaders = this.auth
|
|
25
|
+
? await this.auth({ method: "GET", path, body: null })
|
|
26
|
+
: {};
|
|
27
|
+
const res = await this.fetch(url, {
|
|
28
|
+
method: "GET",
|
|
29
|
+
headers: { Accept: "application/json", ...authHeaders },
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
33
|
+
}
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Push synced data to the server.
|
|
38
|
+
* @param path - The push endpoint path (e.g. "/push/users/abc/settings")
|
|
39
|
+
* @param data - The full document data to push
|
|
40
|
+
* @param baseHash - Hash of the document this push is based on (null for first push)
|
|
41
|
+
* @param authorSignature - Optional author signature for provenance
|
|
42
|
+
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
43
|
+
*/
|
|
44
|
+
async push(path, data, baseHash, authorSignature) {
|
|
45
|
+
const body = JSON.stringify({
|
|
46
|
+
data,
|
|
47
|
+
baseHash,
|
|
48
|
+
...(authorSignature && { authorSignature }),
|
|
49
|
+
});
|
|
50
|
+
const authHeaders = this.auth
|
|
51
|
+
? await this.auth({ method: "POST", path, body })
|
|
52
|
+
: {};
|
|
53
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
Accept: "application/json",
|
|
58
|
+
...authHeaders,
|
|
59
|
+
},
|
|
60
|
+
body,
|
|
61
|
+
});
|
|
62
|
+
if (res.status === 409) {
|
|
63
|
+
throw new ConflictError();
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
67
|
+
}
|
|
68
|
+
return res.json();
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ENCRYPTED_KEY } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
export { ENCRYPTED_KEY };
|
|
3
|
+
/** Encrypt/decrypt interface for client-side E2E encryption. */
|
|
4
|
+
export interface Encryptor {
|
|
5
|
+
encrypt(data: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
6
|
+
decrypt(wrapper: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Creates an Encryptor that uses AES-256-GCM with HKDF-derived keys.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createEncryptor(secret: string, salt: string, info?: string): Encryptor;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getCrypto, getBase64, IV_BYTES, ENCRYPTED_KEY, deriveKey } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
const ALGO = "AES-GCM";
|
|
3
|
+
export { ENCRYPTED_KEY };
|
|
4
|
+
/**
|
|
5
|
+
* Creates an Encryptor that uses AES-256-GCM with HKDF-derived keys.
|
|
6
|
+
*/
|
|
7
|
+
export function createEncryptor(secret, salt, info = "starfish-e2e") {
|
|
8
|
+
if (!secret)
|
|
9
|
+
throw new Error("encryptionSecret must not be empty");
|
|
10
|
+
if (!salt)
|
|
11
|
+
throw new Error("encryptionSalt must not be empty");
|
|
12
|
+
const keyPromise = deriveKey(secret, salt, info);
|
|
13
|
+
return {
|
|
14
|
+
async encrypt(data) {
|
|
15
|
+
const key = await keyPromise;
|
|
16
|
+
const c = getCrypto();
|
|
17
|
+
const b64 = getBase64();
|
|
18
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
19
|
+
const iv = c.getRandomValues(new Uint8Array(IV_BYTES));
|
|
20
|
+
const ciphertext = await c.subtle.encrypt({ name: ALGO, iv }, key, plaintext);
|
|
21
|
+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
|
22
|
+
combined.set(iv);
|
|
23
|
+
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
24
|
+
return { [ENCRYPTED_KEY]: b64.encode(combined) };
|
|
25
|
+
},
|
|
26
|
+
async decrypt(wrapper) {
|
|
27
|
+
const encoded = wrapper[ENCRYPTED_KEY];
|
|
28
|
+
if (typeof encoded !== "string") {
|
|
29
|
+
throw new Error("Expected encrypted data but received unencrypted document");
|
|
30
|
+
}
|
|
31
|
+
const key = await keyPromise;
|
|
32
|
+
const c = getCrypto();
|
|
33
|
+
const b64 = getBase64();
|
|
34
|
+
const combined = b64.decode(encoded);
|
|
35
|
+
if (combined.length < IV_BYTES) {
|
|
36
|
+
throw new Error("Encrypted data is too short");
|
|
37
|
+
}
|
|
38
|
+
const iv = combined.slice(0, IV_BYTES);
|
|
39
|
+
const ciphertext = combined.slice(IV_BYTES);
|
|
40
|
+
try {
|
|
41
|
+
const plaintext = await c.subtle.decrypt({ name: ALGO, iv }, key, ciphertext);
|
|
42
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw new Error("Decryption failed: data may be tampered or key is incorrect", { cause: err });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { configurePlatform } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
export type { CryptoProvider, Base64Provider, PlatformConfig } from "@drakkar.software/starfish-protocol";
|
|
3
|
+
export { stableStringify, computeHash } from "@drakkar.software/starfish-protocol";
|
|
4
|
+
export type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
|
|
5
|
+
export { StarfishClient } from "./client.js";
|
|
6
|
+
export { SyncManager } from "./sync.js";
|
|
7
|
+
export type { SyncManagerOptions } from "./sync.js";
|
|
8
|
+
export { createEncryptor, ENCRYPTED_KEY } from "./crypto.js";
|
|
9
|
+
export type { Encryptor } from "./crypto.js";
|
|
10
|
+
export { ConflictError, StarfishHttpError, } from "./types.js";
|
|
11
|
+
export type { StarfishClientOptions, AuthProvider, ConflictResolver, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { configurePlatform } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
export { stableStringify, computeHash } from "@drakkar.software/starfish-protocol";
|
|
3
|
+
export { StarfishClient } from "./client.js";
|
|
4
|
+
export { SyncManager } from "./sync.js";
|
|
5
|
+
export { createEncryptor, ENCRYPTED_KEY } from "./crypto.js";
|
|
6
|
+
export { ConflictError, StarfishHttpError, } from "./types.js";
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PullResult } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
import type { ConflictResolver } from "./types.js";
|
|
3
|
+
import { StarfishClient } from "./client.js";
|
|
4
|
+
export interface SyncManagerOptions {
|
|
5
|
+
client: StarfishClient;
|
|
6
|
+
pullPath: string;
|
|
7
|
+
pushPath: string;
|
|
8
|
+
/** Custom conflict resolver. Defaults to remote-wins deep merge. Arrays are atomic. */
|
|
9
|
+
onConflict?: ConflictResolver;
|
|
10
|
+
/** Max conflict retry attempts (default: 3). */
|
|
11
|
+
maxRetries?: number;
|
|
12
|
+
encryptionSecret?: string;
|
|
13
|
+
encryptionSalt?: string;
|
|
14
|
+
encryptionInfo?: string;
|
|
15
|
+
signData?: (data: string) => Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
export declare class SyncManager {
|
|
18
|
+
private readonly client;
|
|
19
|
+
private readonly pullPath;
|
|
20
|
+
private readonly pushPath;
|
|
21
|
+
private readonly onConflict;
|
|
22
|
+
private readonly maxRetries;
|
|
23
|
+
private readonly encryptor;
|
|
24
|
+
private readonly signData?;
|
|
25
|
+
private lastHash;
|
|
26
|
+
private lastCheckpoint;
|
|
27
|
+
private localData;
|
|
28
|
+
constructor(options: SyncManagerOptions);
|
|
29
|
+
getData(): Record<string, unknown>;
|
|
30
|
+
getHash(): string | null;
|
|
31
|
+
getCheckpoint(): number;
|
|
32
|
+
pull(): Promise<PullResult>;
|
|
33
|
+
push(data: Record<string, unknown>): Promise<{
|
|
34
|
+
hash: string;
|
|
35
|
+
timestamp: number;
|
|
36
|
+
}>;
|
|
37
|
+
update(modifier: (current: Record<string, unknown>) => Record<string, unknown>): Promise<{
|
|
38
|
+
hash: string;
|
|
39
|
+
timestamp: number;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { deepMerge, stableStringify } from "@drakkar.software/starfish-protocol";
|
|
2
|
+
import { ConflictError } from "./types.js";
|
|
3
|
+
import { createEncryptor } from "./crypto.js";
|
|
4
|
+
export class SyncManager {
|
|
5
|
+
client;
|
|
6
|
+
pullPath;
|
|
7
|
+
pushPath;
|
|
8
|
+
onConflict;
|
|
9
|
+
maxRetries;
|
|
10
|
+
encryptor;
|
|
11
|
+
signData;
|
|
12
|
+
lastHash = null;
|
|
13
|
+
lastCheckpoint = 0;
|
|
14
|
+
localData = {};
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.client = options.client;
|
|
17
|
+
this.pullPath = options.pullPath;
|
|
18
|
+
this.pushPath = options.pushPath;
|
|
19
|
+
this.onConflict = options.onConflict ?? deepMerge;
|
|
20
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
21
|
+
this.signData = options.signData;
|
|
22
|
+
this.encryptor =
|
|
23
|
+
options.encryptionSecret && options.encryptionSalt
|
|
24
|
+
? createEncryptor(options.encryptionSecret, options.encryptionSalt, options.encryptionInfo)
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
27
|
+
getData() {
|
|
28
|
+
return { ...this.localData };
|
|
29
|
+
}
|
|
30
|
+
getHash() {
|
|
31
|
+
return this.lastHash;
|
|
32
|
+
}
|
|
33
|
+
getCheckpoint() {
|
|
34
|
+
return this.lastCheckpoint;
|
|
35
|
+
}
|
|
36
|
+
async pull() {
|
|
37
|
+
const result = await this.client.pull(this.pullPath, this.lastCheckpoint);
|
|
38
|
+
if (this.encryptor) {
|
|
39
|
+
const decrypted = await this.encryptor.decrypt(result.data);
|
|
40
|
+
this.localData = decrypted;
|
|
41
|
+
result.data = decrypted;
|
|
42
|
+
}
|
|
43
|
+
else if (this.lastCheckpoint > 0) {
|
|
44
|
+
this.localData = deepMerge(this.localData, result.data);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
this.localData = result.data;
|
|
48
|
+
}
|
|
49
|
+
this.lastHash = result.hash;
|
|
50
|
+
this.lastCheckpoint = result.timestamp;
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
async push(data) {
|
|
54
|
+
let attempt = 0;
|
|
55
|
+
let pendingData = data;
|
|
56
|
+
while (attempt <= this.maxRetries) {
|
|
57
|
+
try {
|
|
58
|
+
const payload = this.encryptor
|
|
59
|
+
? await this.encryptor.encrypt(pendingData)
|
|
60
|
+
: pendingData;
|
|
61
|
+
const sig = this.signData
|
|
62
|
+
? await this.signData(stableStringify(payload))
|
|
63
|
+
: undefined;
|
|
64
|
+
const result = await this.client.push(this.pushPath, payload, this.lastHash, sig);
|
|
65
|
+
this.lastHash = result.hash;
|
|
66
|
+
this.lastCheckpoint = result.timestamp;
|
|
67
|
+
this.localData = pendingData;
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (!(err instanceof ConflictError) || attempt >= this.maxRetries) {
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
const remote = await this.client.pull(this.pullPath);
|
|
75
|
+
this.lastHash = remote.hash;
|
|
76
|
+
this.lastCheckpoint = remote.timestamp;
|
|
77
|
+
const remoteData = this.encryptor
|
|
78
|
+
? await this.encryptor.decrypt(remote.data)
|
|
79
|
+
: remote.data;
|
|
80
|
+
pendingData = this.onConflict(pendingData, remoteData);
|
|
81
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100 * Math.pow(2, attempt), 2000) + Math.random() * 100));
|
|
82
|
+
attempt++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new ConflictError();
|
|
86
|
+
}
|
|
87
|
+
async update(modifier) {
|
|
88
|
+
await this.pull();
|
|
89
|
+
const updated = modifier(this.localData);
|
|
90
|
+
return this.push(updated);
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Push conflict error (HTTP 409). */
|
|
2
|
+
export declare class ConflictError extends Error {
|
|
3
|
+
constructor();
|
|
4
|
+
}
|
|
5
|
+
/** HTTP error from the Starfish server. */
|
|
6
|
+
export declare class StarfishHttpError extends Error {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
readonly body: string;
|
|
9
|
+
constructor(status: number, body: string);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Auth provider: returns headers to include in requests.
|
|
13
|
+
* Called for every authenticated request (pull and push).
|
|
14
|
+
*/
|
|
15
|
+
export type AuthProvider = (req: {
|
|
16
|
+
method: string;
|
|
17
|
+
path: string;
|
|
18
|
+
body: string | null;
|
|
19
|
+
}) => Record<string, string> | Promise<Record<string, string>>;
|
|
20
|
+
/** Options for creating a StarfishClient. */
|
|
21
|
+
export interface StarfishClientOptions {
|
|
22
|
+
/** Base URL of the Starfish server (e.g. "https://api.example.com/v1"). */
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
/** Auth provider that returns headers for authenticated requests. Optional for public-read collections. */
|
|
25
|
+
auth?: AuthProvider;
|
|
26
|
+
/** Optional fetch implementation (defaults to global fetch). */
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
}
|
|
29
|
+
/** Conflict resolver: given local and remote data, return merged result. */
|
|
30
|
+
export type ConflictResolver = (local: Record<string, unknown>, remote: Record<string, unknown>) => Record<string, unknown>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Push conflict error (HTTP 409). */
|
|
2
|
+
export class ConflictError extends Error {
|
|
3
|
+
constructor() {
|
|
4
|
+
super("hash_mismatch");
|
|
5
|
+
this.name = "ConflictError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** HTTP error from the Starfish server. */
|
|
9
|
+
export class StarfishHttpError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
body;
|
|
12
|
+
constructor(status, body) {
|
|
13
|
+
super(`HTTP ${status}: ${body}`);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.body = body;
|
|
16
|
+
this.name = "StarfishHttpError";
|
|
17
|
+
}
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@drakkar.software/starfish-client",
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/Drakkar-Software/starfish.git",
|
|
7
|
+
"directory": "packages/ts/client"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"description": "TypeScript client SDK for the Starfish sync protocol",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./zustand": {
|
|
22
|
+
"types": "./dist/bindings/zustand.d.ts",
|
|
23
|
+
"import": "./dist/bindings/zustand.js"
|
|
24
|
+
},
|
|
25
|
+
"./legend": {
|
|
26
|
+
"types": "./dist/bindings/legend.d.ts",
|
|
27
|
+
"import": "./dist/bindings/legend.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"zustand": ">=4.0.0",
|
|
32
|
+
"immer": ">=9.0.0",
|
|
33
|
+
"@legendapp/state": ">=2.0.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"zustand": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"immer": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"@legendapp/state": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@drakkar.software/starfish-protocol": "workspace:*"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc --build",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "vitest run"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@legendapp/state": "^2.0.0",
|
|
56
|
+
"hono": "^4.12.7",
|
|
57
|
+
"immer": "^11.1.4",
|
|
58
|
+
"typescript": "^5.5.0",
|
|
59
|
+
"vitest": "^3.0.0",
|
|
60
|
+
"zustand": "^5.0.11"
|
|
61
|
+
},
|
|
62
|
+
"files": [
|
|
63
|
+
"dist"
|
|
64
|
+
]
|
|
65
|
+
}
|