@drakkar.software/starfish-client 3.0.0-alpha.11 → 3.0.0-alpha.13

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/logger.js ADDED
@@ -0,0 +1,80 @@
1
+ /** Console-based sync logger with structured output. */
2
+ export const consoleSyncLogger = {
3
+ pullStart: (s) => console.log(`[starfish:${s}] pull started`),
4
+ pullSuccess: (s, ms, m) => {
5
+ let msg = `[starfish:${s}] pull OK (${ms}ms)`;
6
+ if (m?.bytesTransferred)
7
+ msg += ` ${m.bytesTransferred}B`;
8
+ if (m?.cacheHit)
9
+ msg += ` (cache hit)`;
10
+ console.log(msg);
11
+ },
12
+ pullError: (s, err) => console.error(`[starfish:${s}] pull failed: ${err}`),
13
+ pushStart: (s) => console.log(`[starfish:${s}] push started`),
14
+ pushSuccess: (s, ms, m) => {
15
+ let msg = `[starfish:${s}] push OK (${ms}ms)`;
16
+ if (m?.bytesTransferred)
17
+ msg += ` ${m.bytesTransferred}B`;
18
+ console.log(msg);
19
+ },
20
+ pushError: (s, err) => console.error(`[starfish:${s}] push failed: ${err}`),
21
+ conflict: (s, n) => console.warn(`[starfish:${s}] conflict (attempt ${n})`),
22
+ };
23
+ /** Silent sync logger (no output). */
24
+ export const noopSyncLogger = {
25
+ pullStart: () => { },
26
+ pullSuccess: () => { },
27
+ pullError: () => { },
28
+ pushStart: () => { },
29
+ pushSuccess: () => { },
30
+ pushError: () => { },
31
+ conflict: () => { },
32
+ };
33
+ /** Create a metrics collector that accumulates sync statistics. */
34
+ export function createMetricsCollector() {
35
+ const stores = new Map();
36
+ function ensureStore(name) {
37
+ let s = stores.get(name);
38
+ if (!s) {
39
+ s = { totalPulls: 0, totalPushes: 0, totalDurationMs: 0, totalBytes: 0, totalConflicts: 0 };
40
+ stores.set(name, s);
41
+ }
42
+ return s;
43
+ }
44
+ return {
45
+ recordPull(name, durationMs, metrics) {
46
+ const s = ensureStore(name);
47
+ s.totalPulls++;
48
+ s.totalDurationMs += durationMs;
49
+ if (metrics?.bytesTransferred)
50
+ s.totalBytes += metrics.bytesTransferred;
51
+ },
52
+ recordPush(name, durationMs, metrics) {
53
+ const s = ensureStore(name);
54
+ s.totalPushes++;
55
+ s.totalDurationMs += durationMs;
56
+ if (metrics?.bytesTransferred)
57
+ s.totalBytes += metrics.bytesTransferred;
58
+ },
59
+ recordConflict(name) {
60
+ ensureStore(name).totalConflicts++;
61
+ },
62
+ getSummary() {
63
+ const result = {};
64
+ for (const [name, s] of stores) {
65
+ const totalOps = s.totalPulls + s.totalPushes;
66
+ result[name] = {
67
+ totalPulls: s.totalPulls,
68
+ totalPushes: s.totalPushes,
69
+ avgDurationMs: totalOps > 0 ? Math.round(s.totalDurationMs / totalOps) : 0,
70
+ totalBytes: s.totalBytes,
71
+ totalConflicts: s.totalConflicts,
72
+ };
73
+ }
74
+ return result;
75
+ },
76
+ reset() {
77
+ stores.clear();
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Creates a migration runner that upgrades documents to the current schema version.
3
+ *
4
+ * Given a document with `_schemaVersion`, applies each migration in sequence
5
+ * until the document reaches `currentVersion`. Throws if the document version
6
+ * is ahead of the app (forward compatibility guard).
7
+ */
8
+ export function createMigrator(config) {
9
+ // Eagerly validate the migration chain
10
+ for (let v = 1; v < config.currentVersion; v++) {
11
+ if (!config.migrations[v]) {
12
+ throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
13
+ }
14
+ }
15
+ return (data) => {
16
+ const version = typeof data._schemaVersion === "number" ? data._schemaVersion : 1;
17
+ if (version > config.currentVersion) {
18
+ throw new Error(`Document schema version ${version} is newer than app version ${config.currentVersion}. Update the app.`);
19
+ }
20
+ if (version === config.currentVersion)
21
+ return data;
22
+ let result = { ...data };
23
+ for (let v = version; v < config.currentVersion; v++) {
24
+ const fn = config.migrations[v];
25
+ if (!fn) {
26
+ throw new Error(`Missing migration for version ${v} -> ${v + 1}`);
27
+ }
28
+ try {
29
+ result = fn(result);
30
+ }
31
+ catch (err) {
32
+ throw new Error(`Migration from version ${v} to ${v + 1} failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
33
+ }
34
+ }
35
+ result._schemaVersion = config.currentVersion;
36
+ return result;
37
+ };
38
+ }
@@ -0,0 +1,94 @@
1
+ // ── Implementation ────────────────────────────────────────────────────────────
2
+ /**
3
+ * Wires React Native app lifecycle events to a Starfish store.
4
+ *
5
+ * - **Background**: flushes pending changes before the OS suspends the app.
6
+ * - **Foreground**: pulls remote changes when the user returns to the app.
7
+ * - **NetInfo**: forwards connectivity changes to `store.getState().setOnline()`.
8
+ *
9
+ * Uses dependency injection so no `react-native` or `netinfo` imports are needed
10
+ * in this package. Pass the modules directly:
11
+ *
12
+ * ```ts
13
+ * import { AppState } from "react-native"
14
+ * import NetInfo from "@react-native-community/netinfo"
15
+ * import { createMobileLifecycle } from "@drakkar.software/starfish-client"
16
+ *
17
+ * // Call once, after the store is created:
18
+ * const cleanup = createMobileLifecycle(
19
+ * store,
20
+ * { appState: AppState, netInfo: NetInfo },
21
+ * )
22
+ *
23
+ * // In a React component (e.g. root layout):
24
+ * useEffect(() => cleanup, [])
25
+ * ```
26
+ *
27
+ * @returns A cleanup function that removes all event listeners.
28
+ */
29
+ export function createMobileLifecycle(store, deps, options = {}) {
30
+ const { pullOnForeground = true, flushOnBackground = true } = options;
31
+ const appSub = deps.appState.addEventListener("change", (appState) => {
32
+ if (appState === "background" && flushOnBackground) {
33
+ if (store.getState().dirty) {
34
+ store.getState().flush().catch((err) => { console.error("[Starfish] background flush failed:", err); });
35
+ }
36
+ }
37
+ else if (appState === "active" && pullOnForeground) {
38
+ const { online, syncing } = store.getState();
39
+ if (online && !syncing) {
40
+ store.getState().pull().catch((err) => { console.error("[Starfish] foreground pull failed:", err); });
41
+ }
42
+ }
43
+ // "inactive" (iOS transition) and other states are intentionally ignored
44
+ });
45
+ let netUnsub = null;
46
+ if (deps.netInfo) {
47
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
48
+ store.getState().setOnline(!!isConnected);
49
+ });
50
+ }
51
+ return () => {
52
+ appSub.remove();
53
+ netUnsub?.();
54
+ };
55
+ }
56
+ /**
57
+ * Wires React Native app lifecycle events to an append-log store
58
+ * (`createStarfishLog`). A log is read-only, so this only pulls on foreground
59
+ * (there is nothing to flush on background). NetInfo connectivity changes are
60
+ * forwarded to `store.getState().setOnline()`.
61
+ *
62
+ * ```ts
63
+ * import { AppState } from "react-native"
64
+ * import NetInfo from "@react-native-community/netinfo"
65
+ * import { createStarfishLog, createAppendLogMobileLifecycle } from "@drakkar.software/starfish-client"
66
+ *
67
+ * const store = createStarfishLog({ cursor })
68
+ * const cleanup = createAppendLogMobileLifecycle(store, { appState: AppState, netInfo: NetInfo })
69
+ * useEffect(() => cleanup, [])
70
+ * ```
71
+ *
72
+ * @returns A cleanup function that removes all event listeners.
73
+ */
74
+ export function createAppendLogMobileLifecycle(store, deps, options = {}) {
75
+ const { pullOnForeground = true } = options;
76
+ const appSub = deps.appState.addEventListener("change", (appState) => {
77
+ if (appState === "active" && pullOnForeground) {
78
+ const { online, loading } = store.getState();
79
+ if (online && !loading) {
80
+ store.getState().pull().catch((err) => { console.error("[Starfish] foreground log pull failed:", err); });
81
+ }
82
+ }
83
+ });
84
+ let netUnsub = null;
85
+ if (deps.netInfo) {
86
+ netUnsub = deps.netInfo.addEventListener(({ isConnected }) => {
87
+ store.getState().setOnline(!!isConnected);
88
+ });
89
+ }
90
+ return () => {
91
+ appSub.remove();
92
+ netUnsub?.();
93
+ };
94
+ }
@@ -0,0 +1,92 @@
1
+ // ── Types ─────────────────────────────────────────────────────────────────────
2
+ // ── Implementation ────────────────────────────────────────────────────────────
3
+ /**
4
+ * Creates a multi-store sync coordinator.
5
+ *
6
+ * Collects multiple application stores into a single Starfish sync document,
7
+ * with versioned schema migrations for backward compatibility.
8
+ *
9
+ * ```ts
10
+ * const multiSync = createMultiStoreSync({
11
+ * slices: {
12
+ * tasks: {
13
+ * serialize: () => taskStore.getState().tasks,
14
+ * restore: (tasks) => taskStore.setState({ tasks }),
15
+ * },
16
+ * settings: {
17
+ * serialize: () => settingsStore.getState().settings,
18
+ * restore: (settings) => settingsStore.setState({ settings }),
19
+ * },
20
+ * },
21
+ * version: 2,
22
+ * migrations: {
23
+ * // data from version 1 → upgrade to version 2
24
+ * 1: (data) => ({ ...data, settings: { ...(data.settings as object), darkMode: false } }),
25
+ * },
26
+ * })
27
+ *
28
+ * // Push:
29
+ * starfishStore.getState().set(() => multiSync.serialize())
30
+ *
31
+ * // Restore on pull (pass as onRemoteUpdate to createStarfishStore):
32
+ * createStarfishStore({
33
+ * name: "app",
34
+ * syncManager,
35
+ * onRemoteUpdate: (doc) => multiSync.restore(doc as BackupDocument),
36
+ * })
37
+ * ```
38
+ */
39
+ export function createMultiStoreSync(options) {
40
+ const { slices, version, migrations = {} } = options;
41
+ // Validate migration chain at construction time (fail fast)
42
+ for (const fromVersion of Object.keys(migrations)) {
43
+ const v = Number(fromVersion);
44
+ if (isNaN(v) || v < 1) {
45
+ throw new Error(`Migration key must be a positive integer, got: "${fromVersion}"`);
46
+ }
47
+ }
48
+ function serialize() {
49
+ const data = {};
50
+ for (const key of Object.keys(slices)) {
51
+ data[key] = slices[key].serialize();
52
+ }
53
+ return { version, timestamp: Date.now(), data };
54
+ }
55
+ function restore(doc) {
56
+ if (typeof doc !== "object" || doc === null) {
57
+ throw new Error("restore: expected a BackupDocument object");
58
+ }
59
+ const docVersion = doc.version ?? 1;
60
+ if (typeof docVersion !== "number" || !Number.isInteger(docVersion) || docVersion < 1) {
61
+ throw new Error(`restore: invalid document version: ${String(doc.version)}`);
62
+ }
63
+ if (docVersion > version) {
64
+ throw new Error(`restore: document version ${docVersion} is newer than current version ${version}. ` +
65
+ `Update the app to restore this backup.`);
66
+ }
67
+ // Run migrations sequentially from docVersion up to current version
68
+ let data = typeof doc.data === "object" && doc.data !== null
69
+ ? { ...doc.data }
70
+ : {};
71
+ for (let v = docVersion; v < version; v++) {
72
+ const migration = migrations[v];
73
+ if (!migration)
74
+ continue;
75
+ try {
76
+ data = migration(data);
77
+ }
78
+ catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ throw new Error(`restore: migration from version ${v} to ${v + 1} failed: ${msg}`);
81
+ }
82
+ }
83
+ // Restore each slice
84
+ for (const key of Object.keys(slices)) {
85
+ const sliceData = data[key];
86
+ if (sliceData !== undefined) {
87
+ slices[key].restore(sliceData);
88
+ }
89
+ }
90
+ }
91
+ return { serialize, restore, version };
92
+ }
@@ -0,0 +1,52 @@
1
+ const DEFAULT_INTERVALS = {
2
+ "slow-2g": 120_000,
3
+ "2g": 60_000,
4
+ "3g": 30_000,
5
+ "4g": 10_000,
6
+ };
7
+ const DEFAULT_FALLBACK_MS = 15_000;
8
+ /**
9
+ * Start periodic pulling at a fixed interval.
10
+ * Skips pulls when offline or already syncing.
11
+ * Returns a cleanup function that stops polling.
12
+ */
13
+ export function startPolling(pullFn, getState, intervalMs = 30_000) {
14
+ const timer = setInterval(() => {
15
+ const { online, syncing } = getState();
16
+ if (online && !syncing)
17
+ pullFn().catch((err) => { console.error("[Starfish] poll failed:", err); });
18
+ }, intervalMs);
19
+ return () => clearInterval(timer);
20
+ }
21
+ /**
22
+ * Start polling with adaptive intervals based on network quality.
23
+ * Uses the Network Information API (`navigator.connection.effectiveType`) when available.
24
+ * Returns controls to pause, resume, or stop polling.
25
+ */
26
+ export function startAdaptivePolling(pullFn, getState, options) {
27
+ let intervalMs;
28
+ if (options?.intervalMs != null) {
29
+ intervalMs = options.intervalMs;
30
+ }
31
+ else {
32
+ const intervals = options?.intervals ?? DEFAULT_INTERVALS;
33
+ let effectiveType;
34
+ if (typeof navigator !== "undefined" && "connection" in navigator) {
35
+ effectiveType = navigator.connection.effectiveType;
36
+ }
37
+ intervalMs = (effectiveType != null ? intervals[effectiveType] : undefined) ?? DEFAULT_FALLBACK_MS;
38
+ }
39
+ let paused = false;
40
+ const timer = setInterval(() => {
41
+ if (paused)
42
+ return;
43
+ const { online, syncing } = getState();
44
+ if (online && !syncing)
45
+ pullFn().catch((err) => { console.error("[Starfish] adaptive poll failed:", err); });
46
+ }, intervalMs);
47
+ return {
48
+ pause: () => { paused = true; },
49
+ resume: () => { paused = false; },
50
+ stop: () => clearInterval(timer),
51
+ };
52
+ }
@@ -0,0 +1,223 @@
1
+ /** Shallow structural comparison of two values. Handles objects, arrays, and primitives. */
2
+ function shallowEqual(a, b) {
3
+ if (a === b)
4
+ return true;
5
+ if (a == null || b == null)
6
+ return a === b;
7
+ if (typeof a !== typeof b)
8
+ return false;
9
+ if (typeof a !== "object")
10
+ return false;
11
+ if (Array.isArray(a) !== Array.isArray(b))
12
+ return false;
13
+ if (Array.isArray(a) && Array.isArray(b)) {
14
+ if (a.length !== b.length)
15
+ return false;
16
+ return a.every((v, i) => shallowEqual(v, b[i]));
17
+ }
18
+ const aObj = a;
19
+ const bObj = b;
20
+ const aKeys = Object.keys(aObj);
21
+ const bKeys = Object.keys(bObj);
22
+ if (aKeys.length !== bKeys.length)
23
+ return false;
24
+ return aKeys.every((k) => shallowEqual(aObj[k], bObj[k]));
25
+ }
26
+ /**
27
+ * Wrap a standard ConflictResolver to also return metadata about which fields conflicted.
28
+ * Compares local and remote keys to detect differing fields.
29
+ */
30
+ export function withConflictMeta(resolver) {
31
+ return (local, remote) => {
32
+ const conflictedFields = [];
33
+ const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
34
+ for (const key of allKeys) {
35
+ const lv = local[key];
36
+ const rv = remote[key];
37
+ if (!shallowEqual(lv, rv)) {
38
+ conflictedFields.push(key);
39
+ }
40
+ }
41
+ const data = resolver(local, remote);
42
+ // Determine how it was resolved using structural comparison
43
+ let resolvedBy = "merged";
44
+ if (shallowEqual(data, local))
45
+ resolvedBy = "local";
46
+ else if (shallowEqual(data, remote))
47
+ resolvedBy = "remote";
48
+ return {
49
+ data,
50
+ meta: {
51
+ conflictedFields,
52
+ resolvedBy,
53
+ timestamp: Date.now(),
54
+ },
55
+ };
56
+ };
57
+ }
58
+ /** Compare two timestamp values. Handles both numeric (epoch) and string (ISO-8601) timestamps. */
59
+ function compareTimestamps(a, b) {
60
+ if (typeof a === "number" && typeof b === "number")
61
+ return a >= b;
62
+ return String(a ?? "") >= String(b ?? "");
63
+ }
64
+ /**
65
+ * Creates a conflict resolver that merges arrays by ID with per-item
66
+ * timestamp comparison, and uses document-level timestamp for scalars.
67
+ *
68
+ * For arrays: builds a union of both sets keyed by `idKey`. When both
69
+ * sides have the same item, the one with the newer `timestampKey` wins.
70
+ * For scalars: the document with the newer `documentTimestampKey` wins.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const merge = createUnionMerge()
75
+ * const sync = new SyncManager({ ..., onConflict: merge })
76
+ * ```
77
+ */
78
+ export function createUnionMerge(options) {
79
+ const idKey = options?.idKey ?? "id";
80
+ const tsKey = options?.timestampKey ?? "updatedAt";
81
+ const docTsKey = options?.documentTimestampKey ?? "timestamp";
82
+ return (local, remote) => {
83
+ const result = {};
84
+ const localNewer = compareTimestamps(local[docTsKey], remote[docTsKey]);
85
+ const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
86
+ for (const key of allKeys) {
87
+ const lv = local[key];
88
+ const rv = remote[key];
89
+ // Both sides have arrays — attempt ID-based union
90
+ if (Array.isArray(lv) && Array.isArray(rv)) {
91
+ const map = new Map();
92
+ // Seed with remote items
93
+ for (const item of rv) {
94
+ if (item && typeof item === "object" && idKey in item) {
95
+ map.set(item[idKey], item);
96
+ }
97
+ else {
98
+ map.set(Symbol(), item);
99
+ }
100
+ }
101
+ // Overlay local items (per-item timestamp wins)
102
+ for (const item of lv) {
103
+ if (item && typeof item === "object" && idKey in item) {
104
+ const localItem = item;
105
+ const id = localItem[idKey];
106
+ const remoteItem = map.get(id);
107
+ if (!remoteItem) {
108
+ map.set(id, localItem);
109
+ }
110
+ else {
111
+ if (compareTimestamps(localItem[tsKey], remoteItem[tsKey])) {
112
+ map.set(id, localItem);
113
+ }
114
+ }
115
+ }
116
+ else {
117
+ map.set(Symbol(), item);
118
+ }
119
+ }
120
+ result[key] = [...map.values()];
121
+ }
122
+ else if (lv !== undefined && rv !== undefined) {
123
+ // Scalar: document-level timestamp wins
124
+ result[key] = localNewer ? lv : rv;
125
+ }
126
+ else {
127
+ // Only one side has the key
128
+ result[key] = lv ?? rv;
129
+ }
130
+ }
131
+ return result;
132
+ };
133
+ }
134
+ /**
135
+ * Creates a conflict resolver that handles soft-deleted items (tombstones).
136
+ * Extends union merge with tombstone awareness: if an item exists on one side
137
+ * with a `deletedAtKey` set, that deletion is respected even if the other side
138
+ * still has the item alive — as long as the deletion timestamp is newer.
139
+ */
140
+ export function createSoftDeleteResolver(options) {
141
+ const idKey = options?.idKey ?? "id";
142
+ const tsKey = options?.timestampKey ?? "updatedAt";
143
+ const deletedAtKey = options?.deletedAtKey ?? "_deletedAt";
144
+ const baseMerge = createUnionMerge(options);
145
+ return (local, remote) => {
146
+ const merged = baseMerge(local, remote);
147
+ // Build a tombstone map from both sides: id → deletedAt timestamp
148
+ const tombstones = new Map();
149
+ for (const source of [local, remote]) {
150
+ for (const key of Object.keys(source)) {
151
+ const arr = source[key];
152
+ if (!Array.isArray(arr))
153
+ continue;
154
+ for (const item of arr) {
155
+ if (item && typeof item === "object" && idKey in item && deletedAtKey in item) {
156
+ const rec = item;
157
+ const id = rec[idKey];
158
+ const deletedAt = rec[deletedAtKey];
159
+ if (typeof deletedAt === "number" || typeof deletedAt === "string") {
160
+ const existing = tombstones.get(id);
161
+ if (existing == null || compareTimestamps(deletedAt, existing))
162
+ tombstones.set(id, deletedAt);
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ // For merged arrays, ensure tombstoned items stay deleted
169
+ // (don't resurrect an item if its tombstone is newer than its updatedAt)
170
+ for (const key of Object.keys(merged)) {
171
+ const value = merged[key];
172
+ if (!Array.isArray(value))
173
+ continue;
174
+ merged[key] = value.filter((item) => {
175
+ if (!item || typeof item !== "object" || !(idKey in item))
176
+ return true;
177
+ const rec = item;
178
+ const id = rec[idKey];
179
+ const deletedAt = tombstones.get(id);
180
+ if (deletedAt == null)
181
+ return true;
182
+ // Keep the item if it has a deletedAt (it's the tombstone itself)
183
+ if (rec[deletedAtKey] != null)
184
+ return true;
185
+ // Filter out alive items that have a newer tombstone
186
+ return compareTimestamps(rec[tsKey], deletedAt) && rec[tsKey] !== deletedAt;
187
+ });
188
+ }
189
+ return merged;
190
+ };
191
+ }
192
+ /**
193
+ * Simple resolver: the document with the newer timestamp wins entirely.
194
+ * No per-field or per-item merging.
195
+ */
196
+ export function timestampWinner(timestampKey = "timestamp") {
197
+ return (local, remote) => {
198
+ return compareTimestamps(local[timestampKey], remote[timestampKey])
199
+ ? local
200
+ : remote;
201
+ };
202
+ }
203
+ /**
204
+ * Remove expired tombstones from an array of items.
205
+ * Items with a `deletedAtKey` older than `ttlMs` are pruned.
206
+ *
207
+ * @param items - Array of items, some with a deletedAt timestamp
208
+ * @param ttlMs - Time-to-live in ms for tombstones (default: 30 days)
209
+ * @param deletedAtKey - Key marking deletion timestamp (default: "_deletedAt")
210
+ */
211
+ export function pruneTombstones(items, ttlMs = 30 * 24 * 60 * 60 * 1000, deletedAtKey = "_deletedAt") {
212
+ const cutoff = Date.now() - ttlMs;
213
+ return items.filter((item) => {
214
+ const deletedAt = item[deletedAtKey];
215
+ if (deletedAt == null)
216
+ return true;
217
+ if (typeof deletedAt === "number")
218
+ return deletedAt > cutoff;
219
+ if (typeof deletedAt === "string")
220
+ return new Date(deletedAt).getTime() > cutoff;
221
+ return false;
222
+ });
223
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Service Worker utilities for offline support and PWA functionality.
3
+ */
4
+ /** Check if service workers are supported in the current environment. */
5
+ export function isServiceWorkerSupported() {
6
+ return typeof navigator !== "undefined" && "serviceWorker" in navigator;
7
+ }
8
+ /**
9
+ * Register a service worker for offline support.
10
+ * Returns the registration, or null if not supported.
11
+ */
12
+ export async function registerServiceWorker(scriptUrl, opts) {
13
+ if (!isServiceWorkerSupported())
14
+ return null;
15
+ try {
16
+ const registration = await navigator.serviceWorker.register(scriptUrl, {
17
+ scope: opts?.scope,
18
+ });
19
+ if (opts?.onUpdate) {
20
+ registration.onupdatefound = () => {
21
+ const installingWorker = registration.installing;
22
+ if (installingWorker) {
23
+ installingWorker.onstatechange = () => {
24
+ if (installingWorker.state === "installed" &&
25
+ navigator.serviceWorker.controller) {
26
+ opts.onUpdate(registration);
27
+ }
28
+ };
29
+ }
30
+ };
31
+ }
32
+ return registration;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Unregister all service worker registrations. Returns true if any were unregistered. */
39
+ export async function unregisterServiceWorkers() {
40
+ if (!isServiceWorkerSupported())
41
+ return false;
42
+ try {
43
+ const registrations = await navigator.serviceWorker.getRegistrations();
44
+ let unregistered = false;
45
+ for (const registration of registrations) {
46
+ const result = await registration.unregister();
47
+ if (result)
48
+ unregistered = true;
49
+ }
50
+ return unregistered;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }