@drakkar.software/starfish-client 1.4.1 → 1.6.0
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/background-sync.d.ts +16 -0
- package/dist/background-sync.js +29 -0
- package/dist/bindings/suspense.d.ts +25 -0
- package/dist/bindings/suspense.js +49 -0
- package/dist/bindings/zustand.d.ts +36 -0
- package/dist/bindings/zustand.js +35 -15
- package/dist/debounced-sync.d.ts +66 -0
- package/dist/debounced-sync.js +65 -0
- package/dist/dedup.d.ts +6 -0
- package/dist/dedup.js +35 -0
- package/dist/export.d.ts +24 -0
- package/dist/export.js +115 -0
- package/dist/hash.d.ts +10 -0
- package/dist/hash.js +34 -0
- package/dist/identity.d.ts +72 -0
- package/dist/identity.js +161 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +10 -2
- package/dist/logger.d.ts +26 -2
- package/dist/logger.js +62 -2
- package/dist/multi-store.d.ts +135 -0
- package/dist/multi-store.js +92 -0
- package/dist/platform.d.ts +52 -0
- package/dist/platform.js +62 -0
- package/dist/resolvers.d.ts +19 -0
- package/dist/resolvers.js +57 -0
- package/dist/service-worker.d.ts +18 -0
- package/dist/service-worker.js +55 -0
- package/dist/storage/indexeddb.d.ts +17 -0
- package/dist/storage/indexeddb.js +59 -0
- package/package.json +6 -2
- package/dist/bindings/broadcast.d.ts +0 -19
- package/dist/bindings/broadcast.js +0 -65
- package/dist/bindings/react.d.ts +0 -12
- package/dist/bindings/react.js +0 -25
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Sync API integration for pending changes.
|
|
3
|
+
* Uses the Web Background Sync API to retry failed sync operations
|
|
4
|
+
* when connectivity is restored, even if the app is closed.
|
|
5
|
+
*/
|
|
6
|
+
export interface BackgroundSyncOptions {
|
|
7
|
+
/** Sync event tag. Default: "starfish-sync" */
|
|
8
|
+
tag?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Check if the Background Sync API is supported in the current environment. */
|
|
11
|
+
export declare function isBackgroundSyncSupported(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Register a background sync event with the active service worker.
|
|
14
|
+
* Returns true if registration succeeded, false if not supported or no active SW.
|
|
15
|
+
*/
|
|
16
|
+
export declare function registerBackgroundSync(opts?: BackgroundSyncOptions): Promise<boolean>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Sync API integration for pending changes.
|
|
3
|
+
* Uses the Web Background Sync API to retry failed sync operations
|
|
4
|
+
* when connectivity is restored, even if the app is closed.
|
|
5
|
+
*/
|
|
6
|
+
/** Check if the Background Sync API is supported in the current environment. */
|
|
7
|
+
export function isBackgroundSyncSupported() {
|
|
8
|
+
return (typeof navigator !== "undefined" &&
|
|
9
|
+
"serviceWorker" in navigator &&
|
|
10
|
+
"SyncManager" in globalThis);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Register a background sync event with the active service worker.
|
|
14
|
+
* Returns true if registration succeeded, false if not supported or no active SW.
|
|
15
|
+
*/
|
|
16
|
+
export async function registerBackgroundSync(opts) {
|
|
17
|
+
if (!isBackgroundSyncSupported())
|
|
18
|
+
return false;
|
|
19
|
+
const tag = opts?.tag ?? "starfish-sync";
|
|
20
|
+
try {
|
|
21
|
+
const registration = await navigator.serviceWorker.ready;
|
|
22
|
+
// @ts-expect-error - SyncManager types may not be available
|
|
23
|
+
await registration.sync.register(tag);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Suspense integration for Starfish sync data.
|
|
3
|
+
* Creates resources that throw Promises while loading (Suspense protocol).
|
|
4
|
+
*/
|
|
5
|
+
interface SuspenseResource<T> {
|
|
6
|
+
/** Read the resource value. Throws a Promise while pending (Suspense protocol). */
|
|
7
|
+
read(): T;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Create a Suspense-compatible resource from an async fetcher.
|
|
11
|
+
* The first call to `read()` triggers the fetch. While loading, `read()` throws
|
|
12
|
+
* a Promise (which React Suspense catches to show a fallback). Once resolved,
|
|
13
|
+
* `read()` returns the value synchronously.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const resource = createSuspenseResource(() => syncManager.pull())
|
|
18
|
+
* function MyComponent() {
|
|
19
|
+
* const data = resource.read() // throws while loading, returns data when ready
|
|
20
|
+
* return <div>{JSON.stringify(data)}</div>
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSuspenseResource<T>(fetcher: () => Promise<T>): SuspenseResource<T>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Suspense integration for Starfish sync data.
|
|
3
|
+
* Creates resources that throw Promises while loading (Suspense protocol).
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Create a Suspense-compatible resource from an async fetcher.
|
|
7
|
+
* The first call to `read()` triggers the fetch. While loading, `read()` throws
|
|
8
|
+
* a Promise (which React Suspense catches to show a fallback). Once resolved,
|
|
9
|
+
* `read()` returns the value synchronously.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const resource = createSuspenseResource(() => syncManager.pull())
|
|
14
|
+
* function MyComponent() {
|
|
15
|
+
* const data = resource.read() // throws while loading, returns data when ready
|
|
16
|
+
* return <div>{JSON.stringify(data)}</div>
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function createSuspenseResource(fetcher) {
|
|
21
|
+
let status = "pending";
|
|
22
|
+
let result;
|
|
23
|
+
let error;
|
|
24
|
+
let promise = null;
|
|
25
|
+
function init() {
|
|
26
|
+
if (promise)
|
|
27
|
+
return promise;
|
|
28
|
+
promise = fetcher().then((value) => {
|
|
29
|
+
status = "resolved";
|
|
30
|
+
result = value;
|
|
31
|
+
}, (err) => {
|
|
32
|
+
status = "rejected";
|
|
33
|
+
error = err;
|
|
34
|
+
});
|
|
35
|
+
return promise;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
read() {
|
|
39
|
+
switch (status) {
|
|
40
|
+
case "pending":
|
|
41
|
+
throw init();
|
|
42
|
+
case "resolved":
|
|
43
|
+
return result;
|
|
44
|
+
case "rejected":
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -30,6 +30,26 @@ export interface CreateStarfishStoreOptions {
|
|
|
30
30
|
devtools?: boolean | DevtoolsOptions;
|
|
31
31
|
/** Pass `produce` from `immer` to enable draft-based mutations in `set()`. */
|
|
32
32
|
produce?: <T>(base: T, recipe: (draft: T) => T | void) => T;
|
|
33
|
+
/**
|
|
34
|
+
* Called when remote data arrives via `pull()` — **not** called for local `set()` writes.
|
|
35
|
+
*
|
|
36
|
+
* Use this to restore domain stores after a pull without worrying about feedback loops.
|
|
37
|
+
* The callback fires **after** the Starfish store state is updated, so the store already
|
|
38
|
+
* reflects the new data when this runs.
|
|
39
|
+
*
|
|
40
|
+
* Replaces the manual `isRestoring` flag pattern:
|
|
41
|
+
* ```ts
|
|
42
|
+
* createStarfishStore({
|
|
43
|
+
* name: "app",
|
|
44
|
+
* syncManager,
|
|
45
|
+
* onRemoteUpdate: (data) => {
|
|
46
|
+
* taskStore.setState({ tasks: data.tasks as Task[] })
|
|
47
|
+
* settingsStore.setState({ settings: data.settings as Settings })
|
|
48
|
+
* },
|
|
49
|
+
* })
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
onRemoteUpdate?: (data: Record<string, unknown>) => void;
|
|
33
53
|
}
|
|
34
54
|
export type { DevtoolsOptions };
|
|
35
55
|
export declare function createStarfishStore(options: CreateStarfishStoreOptions): StoreApi<StarfishStore>;
|
|
@@ -48,6 +68,22 @@ export declare function useStarfish(store: StoreApi<StarfishStore>): StarfishSto
|
|
|
48
68
|
export declare function useStarfishData<T = Record<string, unknown>>(store: StoreApi<StarfishStore>, selector?: (data: Record<string, unknown>) => T): T;
|
|
49
69
|
/** Use the derived sync status (synced | syncing | pending | error | offline). */
|
|
50
70
|
export declare function useSyncStatus(store: StoreApi<StarfishStore>): SyncStatus;
|
|
71
|
+
/**
|
|
72
|
+
* Subscribe to sync status changes outside of React.
|
|
73
|
+
*
|
|
74
|
+
* Framework-agnostic — works in React Native, Node.js, or anywhere hooks are unavailable.
|
|
75
|
+
* The callback is invoked immediately with the current status and then on every change.
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* const unsub = subscribeSyncStatus(store, (status) => {
|
|
79
|
+
* updateStatusBar(status)
|
|
80
|
+
* })
|
|
81
|
+
*
|
|
82
|
+
* // Later, to stop listening:
|
|
83
|
+
* unsub()
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export declare function subscribeSyncStatus(store: StoreApi<StarfishStore>, callback: (status: SyncStatus) => void): () => void;
|
|
51
87
|
/** Sets up cross-tab sync for a Starfish store. Cleans up on unmount. */
|
|
52
88
|
export declare function useCrossTabSync(store: StoreApi<StarfishStore>, name: string): void;
|
|
53
89
|
/** Binds browser online/offline events to the store's setOnline action. Cleans up on unmount. */
|
package/dist/bindings/zustand.js
CHANGED
|
@@ -19,7 +19,11 @@ export function createStarfishStore(options) {
|
|
|
19
19
|
set({ syncing: true, error: null }, false, "pull/start");
|
|
20
20
|
try {
|
|
21
21
|
await syncManager.pull();
|
|
22
|
-
|
|
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);
|
|
23
27
|
}
|
|
24
28
|
catch (err) {
|
|
25
29
|
set({ syncing: false, error: err instanceof Error ? err.message : String(err) }, false, "pull/error");
|
|
@@ -118,6 +122,32 @@ export function useStarfishData(store, selector) {
|
|
|
118
122
|
export function useSyncStatus(store) {
|
|
119
123
|
return useStore(store, deriveSyncStatus);
|
|
120
124
|
}
|
|
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
|
+
});
|
|
150
|
+
}
|
|
121
151
|
/** Sets up cross-tab sync for a Starfish store. Cleans up on unmount. */
|
|
122
152
|
export function useCrossTabSync(store, name) {
|
|
123
153
|
useEffect(() => {
|
|
@@ -209,15 +239,9 @@ export function useSyncInit(config) {
|
|
|
209
239
|
name: config.storeName ?? "sync",
|
|
210
240
|
syncManager,
|
|
211
241
|
storage: config.storage,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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;
|
|
242
|
+
// onRemoteUpdate fires only for pull() results, never for local set() writes —
|
|
243
|
+
// so no isRestoring flag is needed.
|
|
244
|
+
onRemoteUpdate: (data) => {
|
|
221
245
|
try {
|
|
222
246
|
onDataRef.current?.(data);
|
|
223
247
|
}
|
|
@@ -226,16 +250,12 @@ export function useSyncInit(config) {
|
|
|
226
250
|
error: `onData failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
227
251
|
});
|
|
228
252
|
}
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
lastDataRef = data;
|
|
232
|
-
}
|
|
253
|
+
},
|
|
233
254
|
});
|
|
234
255
|
setStore(newStore);
|
|
235
256
|
// Initial pull — errors are stored in state.error by the pull() action
|
|
236
257
|
newStore.getState().pull().catch(() => { });
|
|
237
258
|
return () => {
|
|
238
|
-
unsub();
|
|
239
259
|
setStore(null);
|
|
240
260
|
};
|
|
241
261
|
// Intentionally depend on serializable config values, not the object reference
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { StoreApi } from "zustand/vanilla";
|
|
2
|
+
import type { StarfishStore } from "./bindings/zustand.js";
|
|
3
|
+
export interface DebouncedSyncOptions {
|
|
4
|
+
/**
|
|
5
|
+
* How long to wait after the last `notify()` call before pushing (default: 2000 ms).
|
|
6
|
+
* Shorter values reduce latency; longer values batch more edits into a single push.
|
|
7
|
+
*/
|
|
8
|
+
delayMs?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Emit a warning when the estimated encrypted payload exceeds this byte count (default: 900 KB).
|
|
11
|
+
* The estimate multiplies the JSON size by 1.34 (base64 overhead for encrypted blobs).
|
|
12
|
+
* Set to `Infinity` to disable.
|
|
13
|
+
*/
|
|
14
|
+
warnBytes?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Block the push when the estimated encrypted payload exceeds this byte count (default: 1 MB).
|
|
17
|
+
* Prevents cryptic 413 errors from the server. Set to `Infinity` to disable.
|
|
18
|
+
*/
|
|
19
|
+
maxBytes?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Serialize store data to a sync document before pushing.
|
|
22
|
+
* Called inside the debounce timer, so it always captures the latest state.
|
|
23
|
+
* If omitted, `store.getState().data` is used as-is.
|
|
24
|
+
*/
|
|
25
|
+
serialize?: (currentData: Record<string, unknown>) => Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* Called when the estimated payload size exceeds `warnBytes` but is still below `maxBytes`.
|
|
28
|
+
* Use to show a warning in the UI.
|
|
29
|
+
*/
|
|
30
|
+
onSizeWarning?: (estimatedBytes: number) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Called when the estimated payload size exceeds `maxBytes`.
|
|
33
|
+
* The push is blocked. Use to alert the user that data needs to be pruned.
|
|
34
|
+
* If omitted, a console error is printed.
|
|
35
|
+
*/
|
|
36
|
+
onSizeExceeded?: (estimatedBytes: number) => void;
|
|
37
|
+
}
|
|
38
|
+
export interface DebouncedSync {
|
|
39
|
+
/**
|
|
40
|
+
* Schedule a push. If called again within `delayMs`, the timer resets.
|
|
41
|
+
* Safe to call on every domain store mutation.
|
|
42
|
+
*/
|
|
43
|
+
notify: () => void;
|
|
44
|
+
/** Cancel any pending debounced push. Does not affect an already-in-flight push. */
|
|
45
|
+
cancel: () => void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Creates a debounced push helper that coalesces rapid mutations into a single sync.
|
|
49
|
+
*
|
|
50
|
+
* Designed to be called on every domain store mutation (e.g., every keystroke).
|
|
51
|
+
* The push is delayed by `delayMs` after the **last** call, so typing quickly
|
|
52
|
+
* results in one push, not one per character.
|
|
53
|
+
*
|
|
54
|
+
* Also estimates the encrypted payload size before pushing and warns / blocks
|
|
55
|
+
* if it approaches the server's body size limit.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* const { notify } = createDebouncedSync(starfishStore, {
|
|
59
|
+
* serialize: () => ({ tasks: taskStore.getState().tasks }),
|
|
60
|
+
* })
|
|
61
|
+
*
|
|
62
|
+
* // Call on every domain store mutation:
|
|
63
|
+
* taskStore.subscribe(() => notify())
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export declare function createDebouncedSync(store: StoreApi<StarfishStore>, options?: DebouncedSyncOptions): DebouncedSync;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ── Implementation ────────────────────────────────────────────────────────────
|
|
2
|
+
const DEFAULT_DELAY_MS = 2000;
|
|
3
|
+
const DEFAULT_WARN_BYTES = 900 * 1024; // 900 KB
|
|
4
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MB
|
|
5
|
+
/**
|
|
6
|
+
* Creates a debounced push helper that coalesces rapid mutations into a single sync.
|
|
7
|
+
*
|
|
8
|
+
* Designed to be called on every domain store mutation (e.g., every keystroke).
|
|
9
|
+
* The push is delayed by `delayMs` after the **last** call, so typing quickly
|
|
10
|
+
* results in one push, not one per character.
|
|
11
|
+
*
|
|
12
|
+
* Also estimates the encrypted payload size before pushing and warns / blocks
|
|
13
|
+
* if it approaches the server's body size limit.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const { notify } = createDebouncedSync(starfishStore, {
|
|
17
|
+
* serialize: () => ({ tasks: taskStore.getState().tasks }),
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* // Call on every domain store mutation:
|
|
21
|
+
* taskStore.subscribe(() => notify())
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function createDebouncedSync(store, options = {}) {
|
|
25
|
+
const { delayMs = DEFAULT_DELAY_MS, warnBytes = DEFAULT_WARN_BYTES, maxBytes = DEFAULT_MAX_BYTES, serialize, onSizeWarning, onSizeExceeded, } = options;
|
|
26
|
+
let timer = null;
|
|
27
|
+
function cancel() {
|
|
28
|
+
if (timer !== null) {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
timer = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function notify() {
|
|
34
|
+
cancel();
|
|
35
|
+
timer = setTimeout(() => {
|
|
36
|
+
timer = null;
|
|
37
|
+
const current = store.getState().data;
|
|
38
|
+
const doc = serialize ? serialize(current) : current;
|
|
39
|
+
// Estimate encrypted payload size. AES-GCM output is similar to input size;
|
|
40
|
+
// base64 encoding adds ~33% overhead, plus a small IV/tag overhead.
|
|
41
|
+
const estimatedBytes = Math.ceil(JSON.stringify(doc).length * 1.34);
|
|
42
|
+
if (estimatedBytes > maxBytes) {
|
|
43
|
+
if (onSizeExceeded) {
|
|
44
|
+
onSizeExceeded(estimatedBytes);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
console.error(`[starfish] Push blocked: estimated payload ${(estimatedBytes / 1024).toFixed(0)} KB ` +
|
|
48
|
+
`exceeds limit of ${(maxBytes / 1024).toFixed(0)} KB. Prune your data before syncing.`);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (estimatedBytes > warnBytes) {
|
|
53
|
+
if (onSizeWarning) {
|
|
54
|
+
onSizeWarning(estimatedBytes);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
console.warn(`[starfish] Payload approaching limit: estimated ${(estimatedBytes / 1024).toFixed(0)} KB ` +
|
|
58
|
+
`(warn threshold: ${(warnBytes / 1024).toFixed(0)} KB).`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
store.getState().set(() => doc);
|
|
62
|
+
}, delayMs);
|
|
63
|
+
}
|
|
64
|
+
return { notify, cancel };
|
|
65
|
+
}
|
package/dist/dedup.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request deduplication: prevents multiple concurrent identical GET requests.
|
|
3
|
+
* If a GET request is in-flight for a URL, subsequent identical GET requests
|
|
4
|
+
* return the same Promise. POST/PUT/DELETE/PATCH are never deduped.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createDedupFetch(baseFetch?: typeof globalThis.fetch): typeof globalThis.fetch;
|
package/dist/dedup.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request deduplication: prevents multiple concurrent identical GET requests.
|
|
3
|
+
* If a GET request is in-flight for a URL, subsequent identical GET requests
|
|
4
|
+
* return the same Promise. POST/PUT/DELETE/PATCH are never deduped.
|
|
5
|
+
*/
|
|
6
|
+
export function createDedupFetch(baseFetch = globalThis.fetch.bind(globalThis)) {
|
|
7
|
+
const inflightGets = new Map();
|
|
8
|
+
return (async (input, init) => {
|
|
9
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
10
|
+
// Only dedup GET requests
|
|
11
|
+
if (method !== "GET") {
|
|
12
|
+
return baseFetch(input, init);
|
|
13
|
+
}
|
|
14
|
+
const url = typeof input === "string"
|
|
15
|
+
? input
|
|
16
|
+
: input instanceof URL
|
|
17
|
+
? input.toString()
|
|
18
|
+
: input.url;
|
|
19
|
+
const existing = inflightGets.get(url);
|
|
20
|
+
if (existing) {
|
|
21
|
+
// Return a clone — the original is reserved for cloning only
|
|
22
|
+
return existing.then((res) => res.clone());
|
|
23
|
+
}
|
|
24
|
+
// Store a promise that resolves to a response we keep solely for cloning.
|
|
25
|
+
// The first caller also gets a clone, ensuring the "master" body is never consumed.
|
|
26
|
+
const promise = baseFetch(input, init)
|
|
27
|
+
.then((res) => res)
|
|
28
|
+
.finally(() => {
|
|
29
|
+
inflightGets.delete(url);
|
|
30
|
+
});
|
|
31
|
+
inflightGets.set(url, promise);
|
|
32
|
+
// First caller also gets a clone so the cached response body stays unconsumed
|
|
33
|
+
return promise.then((res) => res.clone());
|
|
34
|
+
});
|
|
35
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data export/import helpers for Starfish sync data.
|
|
3
|
+
* Supports JSON and CSV formats.
|
|
4
|
+
*/
|
|
5
|
+
export interface ExportOptions {
|
|
6
|
+
/** Output format. Default: "json" */
|
|
7
|
+
format?: "json" | "csv";
|
|
8
|
+
/** Pretty-print JSON output. Default: false */
|
|
9
|
+
pretty?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Export data to a string representation.
|
|
13
|
+
* JSON: serializes the full object.
|
|
14
|
+
* CSV: flattens top-level keys into columns. Array values are JSON-encoded.
|
|
15
|
+
*/
|
|
16
|
+
export declare function exportData(data: Record<string, unknown>, opts?: ExportOptions): string;
|
|
17
|
+
/**
|
|
18
|
+
* Import data from a string representation.
|
|
19
|
+
*/
|
|
20
|
+
export declare function importData(raw: string, format?: "json" | "csv"): Record<string, unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* Export data to a Blob suitable for download.
|
|
23
|
+
*/
|
|
24
|
+
export declare function exportToBlob(data: Record<string, unknown>, opts?: ExportOptions): Blob;
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data export/import helpers for Starfish sync data.
|
|
3
|
+
* Supports JSON and CSV formats.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Export data to a string representation.
|
|
7
|
+
* JSON: serializes the full object.
|
|
8
|
+
* CSV: flattens top-level keys into columns. Array values are JSON-encoded.
|
|
9
|
+
*/
|
|
10
|
+
export function exportData(data, opts) {
|
|
11
|
+
const format = opts?.format ?? "json";
|
|
12
|
+
if (format === "json") {
|
|
13
|
+
return opts?.pretty
|
|
14
|
+
? JSON.stringify(data, null, 2)
|
|
15
|
+
: JSON.stringify(data);
|
|
16
|
+
}
|
|
17
|
+
// CSV export: each top-level key becomes a column
|
|
18
|
+
return toCsv(data);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Import data from a string representation.
|
|
22
|
+
*/
|
|
23
|
+
export function importData(raw, format = "json") {
|
|
24
|
+
if (format === "json") {
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
27
|
+
throw new Error("Expected a JSON object");
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
return fromCsv(raw);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Export data to a Blob suitable for download.
|
|
35
|
+
*/
|
|
36
|
+
export function exportToBlob(data, opts) {
|
|
37
|
+
const format = opts?.format ?? "json";
|
|
38
|
+
const content = exportData(data, opts);
|
|
39
|
+
const mimeType = format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8";
|
|
40
|
+
return new Blob([content], { type: mimeType });
|
|
41
|
+
}
|
|
42
|
+
function toCsv(data) {
|
|
43
|
+
const keys = Object.keys(data);
|
|
44
|
+
const header = keys.map(escapeCsvField).join(",");
|
|
45
|
+
const values = keys.map((k) => {
|
|
46
|
+
const v = data[k];
|
|
47
|
+
if (v === null || v === undefined)
|
|
48
|
+
return "";
|
|
49
|
+
if (typeof v === "object")
|
|
50
|
+
return escapeCsvField(JSON.stringify(v));
|
|
51
|
+
return escapeCsvField(String(v));
|
|
52
|
+
});
|
|
53
|
+
return `${header}\n${values.join(",")}`;
|
|
54
|
+
}
|
|
55
|
+
function fromCsv(raw) {
|
|
56
|
+
const lines = raw.trim().split("\n");
|
|
57
|
+
if (lines.length < 2) {
|
|
58
|
+
throw new Error("CSV must have at least a header row and a data row");
|
|
59
|
+
}
|
|
60
|
+
const headers = parseCsvLine(lines[0]);
|
|
61
|
+
const values = parseCsvLine(lines[1]);
|
|
62
|
+
const result = {};
|
|
63
|
+
for (let i = 0; i < headers.length; i++) {
|
|
64
|
+
const key = headers[i];
|
|
65
|
+
const val = values[i] ?? "";
|
|
66
|
+
// Try to parse JSON values
|
|
67
|
+
try {
|
|
68
|
+
result[key] = JSON.parse(val);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
result[key] = val;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function escapeCsvField(field) {
|
|
77
|
+
if (field.includes(",") || field.includes('"') || field.includes("\n")) {
|
|
78
|
+
return `"${field.replace(/"/g, '""')}"`;
|
|
79
|
+
}
|
|
80
|
+
return field;
|
|
81
|
+
}
|
|
82
|
+
function parseCsvLine(line) {
|
|
83
|
+
const result = [];
|
|
84
|
+
let current = "";
|
|
85
|
+
let inQuotes = false;
|
|
86
|
+
for (let i = 0; i < line.length; i++) {
|
|
87
|
+
const ch = line[i];
|
|
88
|
+
if (inQuotes) {
|
|
89
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
90
|
+
current += '"';
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
else if (ch === '"') {
|
|
94
|
+
inQuotes = false;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
current += ch;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (ch === '"') {
|
|
102
|
+
inQuotes = true;
|
|
103
|
+
}
|
|
104
|
+
else if (ch === ",") {
|
|
105
|
+
result.push(current);
|
|
106
|
+
current = "";
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
current += ch;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
result.push(current);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
package/dist/hash.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic JSON serialization with sorted keys (recursive).
|
|
3
|
+
* Must produce identical output to the server's stableStringify.
|
|
4
|
+
*/
|
|
5
|
+
export declare function stableStringify(value: unknown): string;
|
|
6
|
+
/**
|
|
7
|
+
* Compute SHA-256 hex digest of the stable-stringified data.
|
|
8
|
+
* Works in both browser (crypto.subtle) and Node.js environments.
|
|
9
|
+
*/
|
|
10
|
+
export declare function computeHash(data: Record<string, unknown>): Promise<string>;
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getCrypto } from "./platform.js";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic JSON serialization with sorted keys (recursive).
|
|
4
|
+
* Must produce identical output to the server's stableStringify.
|
|
5
|
+
*/
|
|
6
|
+
export function stableStringify(value) {
|
|
7
|
+
if (value === null || value === undefined)
|
|
8
|
+
return "null";
|
|
9
|
+
if (typeof value === "boolean" || typeof value === "number")
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
if (typeof value === "string")
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return "[" + value.map(v => stableStringify(v)).join(",") + "]";
|
|
15
|
+
}
|
|
16
|
+
if (typeof value === "object") {
|
|
17
|
+
const obj = value;
|
|
18
|
+
const keys = Object.keys(obj).sort();
|
|
19
|
+
const pairs = keys.map(k => JSON.stringify(k) + ":" + stableStringify(obj[k]));
|
|
20
|
+
return "{" + pairs.join(",") + "}";
|
|
21
|
+
}
|
|
22
|
+
return "null";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Compute SHA-256 hex digest of the stable-stringified data.
|
|
26
|
+
* Works in both browser (crypto.subtle) and Node.js environments.
|
|
27
|
+
*/
|
|
28
|
+
export async function computeHash(data) {
|
|
29
|
+
const encoded = new TextEncoder().encode(stableStringify(data));
|
|
30
|
+
const buf = await getCrypto().subtle.digest("SHA-256", encoded);
|
|
31
|
+
return Array.from(new Uint8Array(buf))
|
|
32
|
+
.map(b => b.toString(16).padStart(2, "0"))
|
|
33
|
+
.join("");
|
|
34
|
+
}
|