@drakkar.software/starfish-client 1.3.2 → 1.4.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/broadcast.d.ts +19 -0
- package/dist/bindings/broadcast.js +65 -0
- package/dist/bindings/legend.js +5 -5
- package/dist/bindings/react.d.ts +12 -0
- package/dist/bindings/react.js +25 -0
- package/dist/bindings/zustand.d.ts +53 -1
- package/dist/bindings/zustand.js +184 -5
- package/dist/broadcast.d.ts +36 -0
- package/dist/broadcast.js +83 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +42 -0
- package/dist/fetch.d.ts +53 -0
- package/dist/fetch.js +166 -0
- package/dist/history.d.ts +29 -0
- package/dist/history.js +61 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +7 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +20 -0
- package/dist/migrate.d.ts +16 -0
- package/dist/migrate.js +38 -0
- package/dist/polling.d.ts +28 -0
- package/dist/polling.js +52 -0
- package/dist/resolvers.d.ts +50 -0
- package/dist/resolvers.js +166 -0
- package/dist/sync.d.ts +11 -0
- package/dist/sync.js +54 -20
- package/dist/testing.d.ts +47 -0
- package/dist/testing.js +86 -0
- package/dist/validate.d.ts +27 -0
- package/dist/validate.js +28 -0
- package/package.json +26 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { StoreApi } from "zustand/vanilla";
|
|
2
|
+
import type { StarfishStore } from "./zustand.js";
|
|
3
|
+
/**
|
|
4
|
+
* Syncs a Zustand Starfish store across browser tabs using BroadcastChannel.
|
|
5
|
+
* Returns a cleanup function that closes the channel.
|
|
6
|
+
*/
|
|
7
|
+
export declare function setupBroadcastSync(store: StoreApi<StarfishStore>, name: string): () => void;
|
|
8
|
+
/**
|
|
9
|
+
* Syncs a Zustand Starfish store across browser tabs using storage events.
|
|
10
|
+
* Fallback for environments without BroadcastChannel.
|
|
11
|
+
* Returns a cleanup function.
|
|
12
|
+
*/
|
|
13
|
+
export declare function setupStorageFallback(store: StoreApi<StarfishStore>, name: string): () => void;
|
|
14
|
+
/**
|
|
15
|
+
* Auto-detects the best cross-tab sync mechanism and sets it up.
|
|
16
|
+
* Uses BroadcastChannel when available, falls back to storage events.
|
|
17
|
+
* Returns a cleanup function.
|
|
18
|
+
*/
|
|
19
|
+
export declare function setupCrossTabSync(store: StoreApi<StarfishStore>, name: string): () => void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syncs a Zustand Starfish store across browser tabs using BroadcastChannel.
|
|
3
|
+
* Returns a cleanup function that closes the channel.
|
|
4
|
+
*/
|
|
5
|
+
export function setupBroadcastSync(store, name) {
|
|
6
|
+
const channel = new BroadcastChannel(`starfish-${name}`);
|
|
7
|
+
let lastReceivedData = null;
|
|
8
|
+
channel.onmessage = (event) => {
|
|
9
|
+
lastReceivedData = event.data.data;
|
|
10
|
+
store.setState({ data: event.data.data, dirty: event.data.dirty });
|
|
11
|
+
};
|
|
12
|
+
const unsub = store.subscribe((state, prev) => {
|
|
13
|
+
if (state.data === lastReceivedData)
|
|
14
|
+
return;
|
|
15
|
+
if (state.data !== prev.data || state.dirty !== prev.dirty) {
|
|
16
|
+
channel.postMessage({ data: state.data, dirty: state.dirty });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
return () => {
|
|
20
|
+
unsub();
|
|
21
|
+
channel.close();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Syncs a Zustand Starfish store across browser tabs using storage events.
|
|
26
|
+
* Fallback for environments without BroadcastChannel.
|
|
27
|
+
* Returns a cleanup function.
|
|
28
|
+
*/
|
|
29
|
+
export function setupStorageFallback(store, name) {
|
|
30
|
+
const storageKey = `starfish-broadcast-${name}`;
|
|
31
|
+
let lastReceivedData = null;
|
|
32
|
+
const onStorage = (e) => {
|
|
33
|
+
if (e.key !== storageKey || !e.newValue)
|
|
34
|
+
return;
|
|
35
|
+
const payload = JSON.parse(e.newValue);
|
|
36
|
+
lastReceivedData = payload.data;
|
|
37
|
+
store.setState({ data: payload.data, dirty: payload.dirty });
|
|
38
|
+
};
|
|
39
|
+
globalThis.addEventListener("storage", onStorage);
|
|
40
|
+
const unsub = store.subscribe((state, prev) => {
|
|
41
|
+
if (state.data === lastReceivedData)
|
|
42
|
+
return;
|
|
43
|
+
if (state.data !== prev.data || state.dirty !== prev.dirty) {
|
|
44
|
+
localStorage.setItem(storageKey, JSON.stringify({ data: state.data, dirty: state.dirty }));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return () => {
|
|
48
|
+
unsub();
|
|
49
|
+
globalThis.removeEventListener("storage", onStorage);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Auto-detects the best cross-tab sync mechanism and sets it up.
|
|
54
|
+
* Uses BroadcastChannel when available, falls back to storage events.
|
|
55
|
+
* Returns a cleanup function.
|
|
56
|
+
*/
|
|
57
|
+
export function setupCrossTabSync(store, name) {
|
|
58
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
59
|
+
return setupBroadcastSync(store, name);
|
|
60
|
+
}
|
|
61
|
+
if (typeof globalThis.addEventListener === "function" && typeof localStorage !== "undefined") {
|
|
62
|
+
return setupStorageFallback(store, name);
|
|
63
|
+
}
|
|
64
|
+
return () => { };
|
|
65
|
+
}
|
package/dist/bindings/legend.js
CHANGED
|
@@ -18,7 +18,7 @@ export function createStarfishObservable(options) {
|
|
|
18
18
|
state.dirty.set(false);
|
|
19
19
|
}
|
|
20
20
|
catch (err) {
|
|
21
|
-
state.error.set(err.message);
|
|
21
|
+
state.error.set(err instanceof Error ? err.message : String(err));
|
|
22
22
|
}
|
|
23
23
|
finally {
|
|
24
24
|
state.syncing.set(false);
|
|
@@ -32,7 +32,7 @@ export function createStarfishObservable(options) {
|
|
|
32
32
|
state.data.set(options.syncManager.getData());
|
|
33
33
|
}
|
|
34
34
|
catch (err) {
|
|
35
|
-
state.error.set(err.message);
|
|
35
|
+
state.error.set(err instanceof Error ? err.message : String(err));
|
|
36
36
|
}
|
|
37
37
|
finally {
|
|
38
38
|
state.syncing.set(false);
|
|
@@ -48,16 +48,16 @@ export function createStarfishObservable(options) {
|
|
|
48
48
|
state.dirty.set(true);
|
|
49
49
|
state.error.set(null);
|
|
50
50
|
if (state.online.get())
|
|
51
|
-
flush();
|
|
51
|
+
flush().catch(() => { });
|
|
52
52
|
}
|
|
53
53
|
catch (err) {
|
|
54
|
-
state.error.set(err.message);
|
|
54
|
+
state.error.set(err instanceof Error ? err.message : String(err));
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
const setOnline = (online) => {
|
|
58
58
|
state.online.set(online);
|
|
59
59
|
if (online && state.dirty.get())
|
|
60
|
-
flush();
|
|
60
|
+
flush().catch(() => { });
|
|
61
61
|
};
|
|
62
62
|
return { state, pull, set, flush, setOnline };
|
|
63
63
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StoreApi } from "zustand/vanilla";
|
|
2
|
+
import type { StarfishStore, StarfishState } from "./zustand.js";
|
|
3
|
+
/** Derived sync status for UI display. */
|
|
4
|
+
export type SyncStatus = "synced" | "syncing" | "pending" | "error" | "offline";
|
|
5
|
+
/** Derive a single sync status from store state. */
|
|
6
|
+
export declare function deriveSyncStatus(state: StarfishState): SyncStatus;
|
|
7
|
+
/** Use the full Starfish store state and actions. */
|
|
8
|
+
export declare function useStarfish(store: StoreApi<StarfishStore>): StarfishStore;
|
|
9
|
+
/** Use only the synced data, with an optional selector for fine-grained subscriptions. */
|
|
10
|
+
export declare function useStarfishData<T = Record<string, unknown>>(store: StoreApi<StarfishStore>, selector?: (data: Record<string, unknown>) => T): T;
|
|
11
|
+
/** Use the derived sync status (synced | syncing | pending | error | offline). */
|
|
12
|
+
export declare function useSyncStatus(store: StoreApi<StarfishStore>): SyncStatus;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useStore } from "zustand";
|
|
2
|
+
/** Derive a single sync status from store state. */
|
|
3
|
+
export function deriveSyncStatus(state) {
|
|
4
|
+
if (!state.online)
|
|
5
|
+
return "offline";
|
|
6
|
+
if (state.error)
|
|
7
|
+
return "error";
|
|
8
|
+
if (state.syncing)
|
|
9
|
+
return "syncing";
|
|
10
|
+
if (state.dirty)
|
|
11
|
+
return "pending";
|
|
12
|
+
return "synced";
|
|
13
|
+
}
|
|
14
|
+
/** Use the full Starfish store state and actions. */
|
|
15
|
+
export function useStarfish(store) {
|
|
16
|
+
return useStore(store);
|
|
17
|
+
}
|
|
18
|
+
/** Use only the synced data, with an optional selector for fine-grained subscriptions. */
|
|
19
|
+
export function useStarfishData(store, selector) {
|
|
20
|
+
return useStore(store, (state) => selector ? selector(state.data) : state.data);
|
|
21
|
+
}
|
|
22
|
+
/** Use the derived sync status (synced | syncing | pending | error | offline). */
|
|
23
|
+
export function useSyncStatus(store) {
|
|
24
|
+
return useStore(store, deriveSyncStatus);
|
|
25
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type StoreApi } from "zustand/vanilla";
|
|
2
2
|
import { type StateStorage, type DevtoolsOptions } from "zustand/middleware";
|
|
3
|
-
import
|
|
3
|
+
import { SyncManager } from "../sync.js";
|
|
4
|
+
import type { AuthProvider, ConflictResolver } from "../types.js";
|
|
5
|
+
import type { SyncLogger } from "../logger.js";
|
|
6
|
+
import type { Validator } from "../validate.js";
|
|
4
7
|
export interface StarfishState {
|
|
5
8
|
data: Record<string, unknown>;
|
|
6
9
|
syncing: boolean;
|
|
@@ -11,6 +14,8 @@ export interface StarfishState {
|
|
|
11
14
|
export interface StarfishActions {
|
|
12
15
|
pull: () => Promise<void>;
|
|
13
16
|
set: (modifier: (current: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
17
|
+
/** Update data without marking dirty or triggering flush. Use for restoring pulled data into the store. */
|
|
18
|
+
restore: (data: Record<string, unknown>) => void;
|
|
14
19
|
flush: () => Promise<void>;
|
|
15
20
|
setOnline: (online: boolean) => void;
|
|
16
21
|
}
|
|
@@ -28,3 +33,50 @@ export interface CreateStarfishStoreOptions {
|
|
|
28
33
|
}
|
|
29
34
|
export type { DevtoolsOptions };
|
|
30
35
|
export declare function createStarfishStore(options: CreateStarfishStoreOptions): StoreApi<StarfishStore>;
|
|
36
|
+
/** Derived sync status for UI display. */
|
|
37
|
+
export type SyncStatus = "synced" | "syncing" | "pending" | "error" | "offline";
|
|
38
|
+
/** Derive a single sync status from store state. */
|
|
39
|
+
export declare function deriveSyncStatus(state: StarfishState): SyncStatus;
|
|
40
|
+
/**
|
|
41
|
+
* Aggregate multiple sync statuses into a single worst-case status.
|
|
42
|
+
* Priority (worst first): error > syncing > pending > offline > synced.
|
|
43
|
+
*/
|
|
44
|
+
export declare function aggregateSyncStatus(statuses: SyncStatus[]): SyncStatus;
|
|
45
|
+
/** Use the full Starfish store state and actions. */
|
|
46
|
+
export declare function useStarfish(store: StoreApi<StarfishStore>): StarfishStore;
|
|
47
|
+
/** Use only the synced data, with an optional selector for fine-grained subscriptions. */
|
|
48
|
+
export declare function useStarfishData<T = Record<string, unknown>>(store: StoreApi<StarfishStore>, selector?: (data: Record<string, unknown>) => T): T;
|
|
49
|
+
/** Use the derived sync status (synced | syncing | pending | error | offline). */
|
|
50
|
+
export declare function useSyncStatus(store: StoreApi<StarfishStore>): SyncStatus;
|
|
51
|
+
/** Sets up cross-tab sync for a Starfish store. Cleans up on unmount. */
|
|
52
|
+
export declare function useCrossTabSync(store: StoreApi<StarfishStore>, name: string): void;
|
|
53
|
+
/** Binds browser online/offline events to the store's setOnline action. Cleans up on unmount. */
|
|
54
|
+
export declare function useConnectivity(store: StoreApi<StarfishStore>): void;
|
|
55
|
+
/** Returns a human-readable "last synced" label that updates every 5 seconds. */
|
|
56
|
+
export declare function useLastSynced(store: StoreApi<StarfishStore>): string;
|
|
57
|
+
export interface SyncInitConfig {
|
|
58
|
+
serverUrl: string;
|
|
59
|
+
auth?: AuthProvider;
|
|
60
|
+
pullPath: string;
|
|
61
|
+
pushPath: string;
|
|
62
|
+
encryptionSecret?: string;
|
|
63
|
+
encryptionSalt?: string;
|
|
64
|
+
onConflict?: ConflictResolver;
|
|
65
|
+
/** Called when pulled data arrives. Use to restore domain stores. */
|
|
66
|
+
onData?: (data: Record<string, unknown>) => void;
|
|
67
|
+
storeName?: string;
|
|
68
|
+
storage?: StateStorage | false;
|
|
69
|
+
fetch?: typeof globalThis.fetch;
|
|
70
|
+
logger?: SyncLogger;
|
|
71
|
+
validate?: Validator;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* React hook that manages the full Starfish sync lifecycle.
|
|
75
|
+
*
|
|
76
|
+
* Creates StarfishClient → SyncManager → Zustand store, pulls on mount,
|
|
77
|
+
* calls `onData` when remote data arrives, and tears down on unmount or
|
|
78
|
+
* config change.
|
|
79
|
+
*
|
|
80
|
+
* Pass `null` to disable sync (returns `null`).
|
|
81
|
+
*/
|
|
82
|
+
export declare function useSyncInit(config: SyncInitConfig | null): StoreApi<StarfishStore> | null;
|
package/dist/bindings/zustand.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { createStore } from "zustand/vanilla";
|
|
2
|
+
import { useStore } from "zustand";
|
|
2
3
|
import { persist, devtools, 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";
|
|
3
8
|
export function createStarfishStore(options) {
|
|
4
9
|
const { name, syncManager, storage } = options;
|
|
5
10
|
const storeCreator = (rawSet, get) => {
|
|
@@ -17,7 +22,7 @@ export function createStarfishStore(options) {
|
|
|
17
22
|
set({ data: syncManager.getData(), syncing: false }, false, "pull/success");
|
|
18
23
|
}
|
|
19
24
|
catch (err) {
|
|
20
|
-
set({ syncing: false, error: err.message }, false, "pull/error");
|
|
25
|
+
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
|
|
21
26
|
}
|
|
22
27
|
},
|
|
23
28
|
set: (modifier) => {
|
|
@@ -27,12 +32,15 @@ export function createStarfishStore(options) {
|
|
|
27
32
|
: modifier(get().data);
|
|
28
33
|
set({ data: next, dirty: true, error: null }, false, "set");
|
|
29
34
|
if (get().online)
|
|
30
|
-
get().flush();
|
|
35
|
+
get().flush().catch(() => { });
|
|
31
36
|
}
|
|
32
37
|
catch (err) {
|
|
33
|
-
set({ error: err.message }, false, "set/error");
|
|
38
|
+
set({ error: err instanceof Error ? err.message : String(err) }, false, "set/error");
|
|
34
39
|
}
|
|
35
40
|
},
|
|
41
|
+
restore: (data) => {
|
|
42
|
+
set({ data }, false, "restore");
|
|
43
|
+
},
|
|
36
44
|
flush: async () => {
|
|
37
45
|
if (get().syncing || !get().dirty)
|
|
38
46
|
return;
|
|
@@ -42,13 +50,13 @@ export function createStarfishStore(options) {
|
|
|
42
50
|
set({ data: syncManager.getData(), syncing: false, dirty: false }, false, "flush/success");
|
|
43
51
|
}
|
|
44
52
|
catch (err) {
|
|
45
|
-
set({ syncing: false, error: err.message }, false, "flush/error");
|
|
53
|
+
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "flush/error");
|
|
46
54
|
}
|
|
47
55
|
},
|
|
48
56
|
setOnline: (online) => {
|
|
49
57
|
set({ online }, false, "setOnline");
|
|
50
58
|
if (online && get().dirty)
|
|
51
|
-
get().flush();
|
|
59
|
+
get().flush().catch(() => { });
|
|
52
60
|
},
|
|
53
61
|
};
|
|
54
62
|
};
|
|
@@ -71,3 +79,174 @@ export function createStarfishStore(options) {
|
|
|
71
79
|
}
|
|
72
80
|
return createStore()(withSelector);
|
|
73
81
|
}
|
|
82
|
+
/** Derive a single sync status from store state. */
|
|
83
|
+
export function deriveSyncStatus(state) {
|
|
84
|
+
if (!state.online)
|
|
85
|
+
return "offline";
|
|
86
|
+
if (state.error)
|
|
87
|
+
return "error";
|
|
88
|
+
if (state.syncing)
|
|
89
|
+
return "syncing";
|
|
90
|
+
if (state.dirty)
|
|
91
|
+
return "pending";
|
|
92
|
+
return "synced";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Aggregate multiple sync statuses into a single worst-case status.
|
|
96
|
+
* Priority (worst first): error > syncing > pending > offline > synced.
|
|
97
|
+
*/
|
|
98
|
+
export function aggregateSyncStatus(statuses) {
|
|
99
|
+
if (statuses.includes("error"))
|
|
100
|
+
return "error";
|
|
101
|
+
if (statuses.includes("syncing"))
|
|
102
|
+
return "syncing";
|
|
103
|
+
if (statuses.includes("pending"))
|
|
104
|
+
return "pending";
|
|
105
|
+
if (statuses.includes("offline"))
|
|
106
|
+
return "offline";
|
|
107
|
+
return "synced";
|
|
108
|
+
}
|
|
109
|
+
/** Use the full Starfish store state and actions. */
|
|
110
|
+
export function useStarfish(store) {
|
|
111
|
+
return useStore(store);
|
|
112
|
+
}
|
|
113
|
+
/** Use only the synced data, with an optional selector for fine-grained subscriptions. */
|
|
114
|
+
export function useStarfishData(store, selector) {
|
|
115
|
+
return useStore(store, (state) => selector ? selector(state.data) : state.data);
|
|
116
|
+
}
|
|
117
|
+
/** Use the derived sync status (synced | syncing | pending | error | offline). */
|
|
118
|
+
export function useSyncStatus(store) {
|
|
119
|
+
return useStore(store, deriveSyncStatus);
|
|
120
|
+
}
|
|
121
|
+
/** Sets up cross-tab sync for a Starfish store. Cleans up on unmount. */
|
|
122
|
+
export function useCrossTabSync(store, name) {
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
return setupCrossTabSync(store, name);
|
|
125
|
+
}, [store, name]);
|
|
126
|
+
}
|
|
127
|
+
/** Binds browser online/offline events to the store's setOnline action. Cleans up on unmount. */
|
|
128
|
+
export function useConnectivity(store) {
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const handleOnline = () => store.getState().setOnline(true);
|
|
131
|
+
const handleOffline = () => store.getState().setOnline(false);
|
|
132
|
+
window.addEventListener("online", handleOnline);
|
|
133
|
+
window.addEventListener("offline", handleOffline);
|
|
134
|
+
return () => {
|
|
135
|
+
window.removeEventListener("online", handleOnline);
|
|
136
|
+
window.removeEventListener("offline", handleOffline);
|
|
137
|
+
};
|
|
138
|
+
}, [store]);
|
|
139
|
+
}
|
|
140
|
+
/** Returns a human-readable "last synced" label that updates every 5 seconds. */
|
|
141
|
+
export function useLastSynced(store) {
|
|
142
|
+
const lastSyncedAt = useRef(null);
|
|
143
|
+
const [label, setLabel] = useState("Never synced");
|
|
144
|
+
const computeLabel = useCallback(() => {
|
|
145
|
+
if (lastSyncedAt.current === null)
|
|
146
|
+
return "Never synced";
|
|
147
|
+
const seconds = Math.floor((Date.now() - lastSyncedAt.current) / 1000);
|
|
148
|
+
if (seconds < 10)
|
|
149
|
+
return "Just now";
|
|
150
|
+
if (seconds < 60)
|
|
151
|
+
return `${seconds}s ago`;
|
|
152
|
+
return `${Math.floor(seconds / 60)}m ago`;
|
|
153
|
+
}, []);
|
|
154
|
+
// Track sync completion
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
let prevSyncing = store.getState().syncing;
|
|
157
|
+
const unsub = store.subscribe((state) => {
|
|
158
|
+
if (prevSyncing && !state.syncing && !state.error) {
|
|
159
|
+
lastSyncedAt.current = Date.now();
|
|
160
|
+
setLabel(computeLabel());
|
|
161
|
+
}
|
|
162
|
+
prevSyncing = state.syncing;
|
|
163
|
+
});
|
|
164
|
+
return unsub;
|
|
165
|
+
}, [store, computeLabel]);
|
|
166
|
+
// Update label periodically
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const timer = setInterval(() => {
|
|
169
|
+
setLabel(computeLabel());
|
|
170
|
+
}, 5000);
|
|
171
|
+
return () => clearInterval(timer);
|
|
172
|
+
}, [computeLabel]);
|
|
173
|
+
return label;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* React hook that manages the full Starfish sync lifecycle.
|
|
177
|
+
*
|
|
178
|
+
* Creates StarfishClient → SyncManager → Zustand store, pulls on mount,
|
|
179
|
+
* calls `onData` when remote data arrives, and tears down on unmount or
|
|
180
|
+
* config change.
|
|
181
|
+
*
|
|
182
|
+
* Pass `null` to disable sync (returns `null`).
|
|
183
|
+
*/
|
|
184
|
+
export function useSyncInit(config) {
|
|
185
|
+
const [store, setStore] = useState(null);
|
|
186
|
+
const onDataRef = useRef(config?.onData);
|
|
187
|
+
onDataRef.current = config?.onData;
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!config) {
|
|
190
|
+
setStore(null);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const client = new StarfishClient({
|
|
194
|
+
baseUrl: config.serverUrl,
|
|
195
|
+
auth: config.auth,
|
|
196
|
+
fetch: config.fetch,
|
|
197
|
+
});
|
|
198
|
+
const syncManager = new SyncManager({
|
|
199
|
+
client,
|
|
200
|
+
pullPath: config.pullPath,
|
|
201
|
+
pushPath: config.pushPath,
|
|
202
|
+
encryptionSecret: config.encryptionSecret,
|
|
203
|
+
encryptionSalt: config.encryptionSalt,
|
|
204
|
+
onConflict: config.onConflict,
|
|
205
|
+
logger: config.logger,
|
|
206
|
+
validate: config.validate,
|
|
207
|
+
});
|
|
208
|
+
const newStore = createStarfishStore({
|
|
209
|
+
name: config.storeName ?? "sync",
|
|
210
|
+
syncManager,
|
|
211
|
+
storage: config.storage,
|
|
212
|
+
});
|
|
213
|
+
// Subscribe to data changes from pulls (not local sets)
|
|
214
|
+
let lastDataRef = newStore.getState().data;
|
|
215
|
+
const unsub = newStore.subscribe((state) => {
|
|
216
|
+
const data = state.data;
|
|
217
|
+
// Only call onData when data changes and store is not dirty
|
|
218
|
+
// (dirty = false means data came from a pull, not a local set)
|
|
219
|
+
if (data !== lastDataRef && !state.dirty) {
|
|
220
|
+
lastDataRef = data;
|
|
221
|
+
try {
|
|
222
|
+
onDataRef.current?.(data);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
newStore.setState({
|
|
226
|
+
error: `onData failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
lastDataRef = data;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
setStore(newStore);
|
|
235
|
+
// Initial pull — errors are stored in state.error by the pull() action
|
|
236
|
+
newStore.getState().pull().catch(() => { });
|
|
237
|
+
return () => {
|
|
238
|
+
unsub();
|
|
239
|
+
setStore(null);
|
|
240
|
+
};
|
|
241
|
+
// Intentionally depend on serializable config values, not the object reference
|
|
242
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
243
|
+
}, [
|
|
244
|
+
config?.serverUrl,
|
|
245
|
+
config?.pullPath,
|
|
246
|
+
config?.pushPath,
|
|
247
|
+
config?.encryptionSecret,
|
|
248
|
+
config?.encryptionSalt,
|
|
249
|
+
config?.storeName,
|
|
250
|
+
]);
|
|
251
|
+
return store;
|
|
252
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Minimal store interface for cross-tab sync. Works with both Zustand and Legend bindings. */
|
|
2
|
+
export interface BroadcastableStore {
|
|
3
|
+
getState(): {
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
dirty: boolean;
|
|
6
|
+
};
|
|
7
|
+
setState(partial: {
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
dirty: boolean;
|
|
10
|
+
}): void;
|
|
11
|
+
subscribe(listener: (state: {
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
dirty: boolean;
|
|
14
|
+
}, prev: {
|
|
15
|
+
data: Record<string, unknown>;
|
|
16
|
+
dirty: boolean;
|
|
17
|
+
}) => void): () => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Syncs a Starfish store across browser tabs using BroadcastChannel.
|
|
21
|
+
* Works with any store that has getState/setState/subscribe (Zustand, Legend adapters, etc.).
|
|
22
|
+
* Returns a cleanup function that closes the channel.
|
|
23
|
+
*/
|
|
24
|
+
export declare function setupBroadcastSync(store: BroadcastableStore, name: string): () => void;
|
|
25
|
+
/**
|
|
26
|
+
* Syncs a Starfish store across browser tabs using storage events.
|
|
27
|
+
* Fallback for environments without BroadcastChannel.
|
|
28
|
+
* Returns a cleanup function.
|
|
29
|
+
*/
|
|
30
|
+
export declare function setupStorageFallback(store: BroadcastableStore, name: string): () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Auto-detects the best cross-tab sync mechanism and sets it up.
|
|
33
|
+
* Uses BroadcastChannel when available, falls back to storage events.
|
|
34
|
+
* Returns a cleanup function.
|
|
35
|
+
*/
|
|
36
|
+
export declare function setupCrossTabSync(store: BroadcastableStore, name: string): () => void;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syncs a Starfish store across browser tabs using BroadcastChannel.
|
|
3
|
+
* Works with any store that has getState/setState/subscribe (Zustand, Legend adapters, etc.).
|
|
4
|
+
* Returns a cleanup function that closes the channel.
|
|
5
|
+
*/
|
|
6
|
+
export function setupBroadcastSync(store, name) {
|
|
7
|
+
const channel = new BroadcastChannel(`starfish-${name}`);
|
|
8
|
+
let lastReceivedData = null;
|
|
9
|
+
channel.onmessage = (event) => {
|
|
10
|
+
const payload = event.data;
|
|
11
|
+
if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object")
|
|
12
|
+
return;
|
|
13
|
+
lastReceivedData = payload.data;
|
|
14
|
+
store.setState({ data: payload.data, dirty: !!payload.dirty });
|
|
15
|
+
};
|
|
16
|
+
const unsub = store.subscribe((state, prev) => {
|
|
17
|
+
if (state.data === lastReceivedData)
|
|
18
|
+
return;
|
|
19
|
+
if (state.data !== prev.data || state.dirty !== prev.dirty) {
|
|
20
|
+
try {
|
|
21
|
+
channel.postMessage({ data: state.data, dirty: state.dirty });
|
|
22
|
+
}
|
|
23
|
+
catch { /* non-serializable data — skip broadcast */ }
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
unsub();
|
|
28
|
+
channel.close();
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Syncs a Starfish store across browser tabs using storage events.
|
|
33
|
+
* Fallback for environments without BroadcastChannel.
|
|
34
|
+
* Returns a cleanup function.
|
|
35
|
+
*/
|
|
36
|
+
export function setupStorageFallback(store, name) {
|
|
37
|
+
const storageKey = `starfish-broadcast-${name}`;
|
|
38
|
+
let lastReceivedData = null;
|
|
39
|
+
const onStorage = (e) => {
|
|
40
|
+
if (e.key !== storageKey || !e.newValue)
|
|
41
|
+
return;
|
|
42
|
+
let payload;
|
|
43
|
+
try {
|
|
44
|
+
payload = JSON.parse(e.newValue);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object")
|
|
50
|
+
return;
|
|
51
|
+
lastReceivedData = payload.data;
|
|
52
|
+
store.setState({ data: payload.data, dirty: !!payload.dirty });
|
|
53
|
+
};
|
|
54
|
+
globalThis.addEventListener("storage", onStorage);
|
|
55
|
+
const unsub = store.subscribe((state, prev) => {
|
|
56
|
+
if (state.data === lastReceivedData)
|
|
57
|
+
return;
|
|
58
|
+
if (state.data !== prev.data || state.dirty !== prev.dirty) {
|
|
59
|
+
try {
|
|
60
|
+
localStorage.setItem(storageKey, JSON.stringify({ data: state.data, dirty: state.dirty }));
|
|
61
|
+
}
|
|
62
|
+
catch { /* quota exceeded or non-serializable — skip */ }
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
unsub();
|
|
67
|
+
globalThis.removeEventListener("storage", onStorage);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Auto-detects the best cross-tab sync mechanism and sets it up.
|
|
72
|
+
* Uses BroadcastChannel when available, falls back to storage events.
|
|
73
|
+
* Returns a cleanup function.
|
|
74
|
+
*/
|
|
75
|
+
export function setupCrossTabSync(store, name) {
|
|
76
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
77
|
+
return setupBroadcastSync(store, name);
|
|
78
|
+
}
|
|
79
|
+
if (typeof globalThis.addEventListener === "function" && typeof localStorage !== "undefined") {
|
|
80
|
+
return setupStorageFallback(store, name);
|
|
81
|
+
}
|
|
82
|
+
return () => { };
|
|
83
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { PullResult, PushSuccess } from "@drakkar.software/starfish-protocol";
|
|
2
2
|
import type { StarfishClientOptions } from "./types.js";
|
|
3
|
+
/** Result of pulling a binary blob from the server. */
|
|
4
|
+
export interface BlobPullResult {
|
|
5
|
+
data: ArrayBuffer;
|
|
6
|
+
/** Content hash from the ETag header. Null if the server didn't include an ETag. */
|
|
7
|
+
hash: string | null;
|
|
8
|
+
contentType: string;
|
|
9
|
+
}
|
|
10
|
+
/** Result of pushing a binary blob to the server. */
|
|
11
|
+
export interface BlobPushResult {
|
|
12
|
+
hash: string;
|
|
13
|
+
}
|
|
3
14
|
/**
|
|
4
15
|
* Low-level HTTP client for the Starfish sync protocol.
|
|
5
16
|
* Handles auth headers and response parsing.
|
|
@@ -24,4 +35,14 @@ export declare class StarfishClient {
|
|
|
24
35
|
* @throws {ConflictError} if the server detects a hash mismatch (409)
|
|
25
36
|
*/
|
|
26
37
|
push(path: string, data: Record<string, unknown>, baseHash: string | null, authorSignature?: string): Promise<PushSuccess>;
|
|
38
|
+
/**
|
|
39
|
+
* Pull binary data from a blob collection.
|
|
40
|
+
* Returns raw bytes with the content hash from the ETag header.
|
|
41
|
+
*/
|
|
42
|
+
pullBlob(path: string): Promise<BlobPullResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Push binary data to a blob collection.
|
|
45
|
+
* Binary collections use last-write-wins (no conflict detection).
|
|
46
|
+
*/
|
|
47
|
+
pushBlob(path: string, data: ArrayBuffer | Uint8Array | Blob, contentType: string): Promise<BlobPushResult>;
|
|
27
48
|
}
|
package/dist/client.js
CHANGED
|
@@ -67,4 +67,46 @@ export class StarfishClient {
|
|
|
67
67
|
}
|
|
68
68
|
return res.json();
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Pull binary data from a blob collection.
|
|
72
|
+
* Returns raw bytes with the content hash from the ETag header.
|
|
73
|
+
*/
|
|
74
|
+
async pullBlob(path) {
|
|
75
|
+
const authHeaders = this.auth
|
|
76
|
+
? await this.auth({ method: "GET", path, body: null })
|
|
77
|
+
: {};
|
|
78
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
79
|
+
method: "GET",
|
|
80
|
+
headers: { Accept: "*/*", ...authHeaders },
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
84
|
+
}
|
|
85
|
+
const etag = res.headers.get("ETag")?.replace(/"/g, "") ?? null;
|
|
86
|
+
const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
|
|
87
|
+
const data = await res.arrayBuffer();
|
|
88
|
+
return { data, hash: etag, contentType };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Push binary data to a blob collection.
|
|
92
|
+
* Binary collections use last-write-wins (no conflict detection).
|
|
93
|
+
*/
|
|
94
|
+
async pushBlob(path, data, contentType) {
|
|
95
|
+
const authHeaders = this.auth
|
|
96
|
+
? await this.auth({ method: "POST", path, body: null })
|
|
97
|
+
: {};
|
|
98
|
+
const res = await this.fetch(`${this.baseUrl}${path}`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"Content-Type": contentType,
|
|
102
|
+
Accept: "application/json",
|
|
103
|
+
...authHeaders,
|
|
104
|
+
},
|
|
105
|
+
body: data,
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw new StarfishHttpError(res.status, await res.text());
|
|
109
|
+
}
|
|
110
|
+
return res.json();
|
|
111
|
+
}
|
|
70
112
|
}
|