@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.
@@ -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. */
@@ -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
- set({ data: syncManager.getData(), syncing: false }, false, "pull/success");
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
- // 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;
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
+ }
@@ -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
+ }
@@ -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
+ }