expo-offline-sync 0.0.0-stage → 1.0.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/README.md +366 -2
- package/dist/index.cjs +1438 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +577 -0
- package/dist/index.d.ts +577 -0
- package/dist/index.js +1415 -0
- package/dist/index.js.map +1 -0
- package/package.json +91 -4
package/README.md
CHANGED
|
@@ -1,3 +1,367 @@
|
|
|
1
|
-
#
|
|
1
|
+
# expo-offline-sync 🚀
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/expo-offline-sync)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
[](https://expo.dev/)
|
|
7
|
+
[](#)
|
|
8
|
+
|
|
9
|
+
> **Deterministic, scoped, engine-agnostic offline mutation & request queue for Expo & React Native.**
|
|
10
|
+
> Add offline-first resilience to your existing REST, GraphQL, or tRPC endpoints in **under 10 minutes** without rewriting your database.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 🌟 Why expo-offline-sync?
|
|
15
|
+
|
|
16
|
+
Existing offline solutions force extreme trade-offs:
|
|
17
|
+
|
|
18
|
+
- **Heavyweight / Invasive (WatermelonDB, RxDB, PowerSync):** Require rewriting your entire schema with custom ORMs, setting up specialized sync backends, or paying for proprietary cloud sync engines.
|
|
19
|
+
- **Legacy & Abandoned (redux-offline):** Bound to Redux, broken in modern Expo / React Native New Architecture.
|
|
20
|
+
- **TanStack Query Mutation Limitations:** Storing pending mutations across app restarts is notoriously tricky and fails when functions/closures cannot be serialized.
|
|
21
|
+
|
|
22
|
+
**`expo-offline-sync` takes the pragmatic approach:**
|
|
23
|
+
Keep your existing backend and state management (Zustand, TanStack Query, Redux, or raw fetch). Whenever the user performs an action while offline (e.g. sending a message, submitting a form, liking a post), it safely queues it into **encrypted/durable local SQLite** and synchronizes deterministically when connectivity returns.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## ✨ Features
|
|
28
|
+
|
|
29
|
+
- 🎯 **Engine-Agnostic:** Works with fetch, Axios, TanStack Query, Zustand, tRPC, GraphQL, or Firebase.
|
|
30
|
+
- 🛡 **Scoped Queuing (No Head-of-Line Blocking):** Mutations can run in independent scopes (e.g. `scope: 'chat:room_1'`). A retry in Room 1 will **never** block a profile update in another scope.
|
|
31
|
+
- 🔁 **Jittered Exponential Backoff:** Smart retry policies prevent server thundering herds.
|
|
32
|
+
- 🔐 **Dynamic Auth Token Injection:** Injects fresh JWT/Bearer tokens at execution time (`getHeaders`). Enqueued mutations won't fail with 401 even after being offline for hours!
|
|
33
|
+
- 💾 **Durable SQLite Storage:** Backed by modern Expo SQLite (SDK 51/52+ fast asynchronous API) with in-memory fallbacks.
|
|
34
|
+
- 🎨 **Optimistic UI & Rollback:** Built-in `useOfflineMutation` hook gives instant UI feedback and clean rollback on permanent failure.
|
|
35
|
+
- 🌙 **Background Sync Support:** Optional plugin for `expo-background-fetch` and `expo-task-manager` to sync while the app is in the background or killed.
|
|
36
|
+
- 🧩 **100% Customizable:** Custom storage adapters, custom retry rules, custom network checks, and request interceptors.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 📦 Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx expo install expo-offline-sync expo-sqlite @react-native-community/netinfo
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
_(Optional: For background sync capabilities)_
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx expo install expo-background-fetch expo-task-manager
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 🚀 Quick Start (5 Minutes)
|
|
55
|
+
|
|
56
|
+
### 1. Initialize the Provider
|
|
57
|
+
|
|
58
|
+
Wrap your app with `OfflineSyncProvider`:
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// App.tsx
|
|
62
|
+
import React from "react";
|
|
63
|
+
import { OfflineSyncProvider } from "expo-offline-sync";
|
|
64
|
+
|
|
65
|
+
export default function App() {
|
|
66
|
+
return (
|
|
67
|
+
<OfflineSyncProvider
|
|
68
|
+
options={{
|
|
69
|
+
// Dynamically resolve auth token before replaying requests
|
|
70
|
+
getHeaders: async () => ({
|
|
71
|
+
Authorization: `Bearer ${await getAuthToken()}`,
|
|
72
|
+
}),
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<MainScreen />
|
|
76
|
+
</OfflineSyncProvider>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 2. Register Mutations (Typed Registry Pattern)
|
|
84
|
+
|
|
85
|
+
Register handlers anywhere during app initialization (e.g. `sync.ts` or inside your API layer):
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { OfflineSyncClient } from "expo-offline-sync";
|
|
89
|
+
|
|
90
|
+
export const syncClient = new OfflineSyncClient();
|
|
91
|
+
|
|
92
|
+
// Register a mutation handler
|
|
93
|
+
syncClient.register(
|
|
94
|
+
"SEND_MESSAGE",
|
|
95
|
+
async (payload: { chatId: string; text: string }) => {
|
|
96
|
+
const response = await fetch(
|
|
97
|
+
`https://api.example.com/chats/${payload.chatId}/messages`,
|
|
98
|
+
{
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ text: payload.text }),
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (!response.ok) throw new Error("Failed to send message");
|
|
106
|
+
return response.json();
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### 3. Use in React (WhatsApp-Style Chat Example)
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import React, { useState } from "react";
|
|
117
|
+
import { View, Text, TextInput, Button } from "react-native";
|
|
118
|
+
import { useOfflineMutation, useQueueStatus } from "expo-offline-sync";
|
|
119
|
+
|
|
120
|
+
export function ChatScreen({ chatId }: { chatId: string }) {
|
|
121
|
+
const [text, setText] = useState("");
|
|
122
|
+
const [messages, setMessages] = useState<any[]>([]);
|
|
123
|
+
|
|
124
|
+
// Hook automatically manages optimistic updates & rollback
|
|
125
|
+
const { mutate } = useOfflineMutation({
|
|
126
|
+
type: "SEND_MESSAGE",
|
|
127
|
+
scope: `chat:${chatId}`, // Scoped FIFO queue for this chat room
|
|
128
|
+
onMutate: (payload, item) => {
|
|
129
|
+
// 1. Instant Optimistic Update
|
|
130
|
+
setMessages((prev) => [
|
|
131
|
+
...prev,
|
|
132
|
+
{ id: item.id, text: payload.text, status: "pending" }, // Shows clock icon 🕒
|
|
133
|
+
]);
|
|
134
|
+
},
|
|
135
|
+
onSuccess: (result, item) => {
|
|
136
|
+
// 2. Mark as delivered ✓✓
|
|
137
|
+
setMessages((prev) =>
|
|
138
|
+
prev.map((m) => (m.id === item.id ? { ...m, status: "sent" } : m)),
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
onError: (error, item) => {
|
|
142
|
+
// 3. Mark as failed ⚠️
|
|
143
|
+
setMessages((prev) =>
|
|
144
|
+
prev.map((m) => (m.id === item.id ? { ...m, status: "failed" } : m)),
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const handleSend = () => {
|
|
150
|
+
mutate({ chatId, text });
|
|
151
|
+
setText("");
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<View>
|
|
156
|
+
{messages.map((m) => (
|
|
157
|
+
<Text key={m.id}>
|
|
158
|
+
{m.text} {m.status === "pending" ? "🕒" : "✓✓"}
|
|
159
|
+
</Text>
|
|
160
|
+
))}
|
|
161
|
+
<TextInput value={text} onChangeText={setText} />
|
|
162
|
+
<Button title="Send" onPress={handleSend} />
|
|
163
|
+
</View>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### 4. Zero-Boilerplate Direct REST Requests (`enqueueHttp`)
|
|
171
|
+
|
|
172
|
+
Don't want to register a mutation handler? Queue any REST request directly:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
await syncClient.enqueueHttp({
|
|
176
|
+
url: "https://api.example.com/v1/posts/like",
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: { postId: "123" },
|
|
179
|
+
scope: "likes", // runs sequentially for likes
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## 🔌 Integrations & Recipes
|
|
186
|
+
|
|
187
|
+
### 1. TanStack Query (React Query) Integration
|
|
188
|
+
Combine the caching power of TanStack Query with `expo-offline-sync`'s persistent SQLite queue:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
192
|
+
import { useOfflineSync } from 'expo-offline-sync';
|
|
193
|
+
|
|
194
|
+
export function useOfflineCreateTodo() {
|
|
195
|
+
const queryClient = useQueryClient();
|
|
196
|
+
const offlineSync = useOfflineSync();
|
|
197
|
+
|
|
198
|
+
return useMutation({
|
|
199
|
+
mutationFn: async (newTodo: { title: string }) => {
|
|
200
|
+
// Enqueues into durable SQLite and executes when online
|
|
201
|
+
return offlineSync.enqueue({
|
|
202
|
+
type: 'CREATE_TODO',
|
|
203
|
+
payload: newTodo,
|
|
204
|
+
scope: 'todos',
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
onMutate: async (newTodo) => {
|
|
208
|
+
// Cancel outgoing refetches and update local query cache optimistically
|
|
209
|
+
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
|
210
|
+
const previousTodos = queryClient.getQueryData(['todos']);
|
|
211
|
+
|
|
212
|
+
queryClient.setQueryData(['todos'], (old: any[] = []) => [
|
|
213
|
+
{ id: 'temp-' + Date.now(), ...newTodo, isPending: true },
|
|
214
|
+
...old,
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
return { previousTodos };
|
|
218
|
+
},
|
|
219
|
+
onError: (err, newTodo, context) => {
|
|
220
|
+
// Rollback cache if mutation failed
|
|
221
|
+
queryClient.setQueryData(['todos'], context?.previousTodos);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### 2. Zustand Store Integration
|
|
228
|
+
Trigger offline mutations directly inside your Zustand store actions:
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
import { create } from 'zustand';
|
|
232
|
+
import { syncClient } from './syncClient';
|
|
233
|
+
|
|
234
|
+
interface CartState {
|
|
235
|
+
items: string[];
|
|
236
|
+
addToCart: (item: string) => Promise<void>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const useCartStore = create<CartState>((set) => ({
|
|
240
|
+
items: [],
|
|
241
|
+
addToCart: async (item) => {
|
|
242
|
+
// 1. Instant local store update
|
|
243
|
+
set((state) => ({ items: [...state.items, item] }));
|
|
244
|
+
|
|
245
|
+
// 2. Queue persistent server sync
|
|
246
|
+
await syncClient.enqueue({
|
|
247
|
+
type: 'ADD_TO_CART',
|
|
248
|
+
payload: { item },
|
|
249
|
+
scope: 'cart',
|
|
250
|
+
onRollback: () => {
|
|
251
|
+
// Rollback state if server rejects
|
|
252
|
+
set((state) => ({ items: state.items.filter((i) => i !== item) }));
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
}));
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## 🎛 Queue Monitoring & Status (`useQueueStatus`)
|
|
262
|
+
|
|
263
|
+
Track real-time queue counts and trigger manual syncs:
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
import { useQueueStatus } from "expo-offline-sync";
|
|
267
|
+
|
|
268
|
+
export function QueueHeader() {
|
|
269
|
+
const { isOnline, isSyncing, stats, sync, retryAll } = useQueueStatus();
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<View>
|
|
273
|
+
<Text>{isOnline ? "🟢 Connected" : "🔴 Offline"}</Text>
|
|
274
|
+
<Text>Pending in SQLite: {stats.pending}</Text>
|
|
275
|
+
<Text>Failed items: {stats.failed}</Text>
|
|
276
|
+
|
|
277
|
+
<Button title="Sync Now" onPress={() => sync()} disabled={isSyncing} />
|
|
278
|
+
{stats.failed > 0 && (
|
|
279
|
+
<Button title="Retry Failed" onPress={() => retryAll()} />
|
|
280
|
+
)}
|
|
281
|
+
</View>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## 🌙 Background Sync (Optional)
|
|
289
|
+
|
|
290
|
+
Replay pending requests even if the user closed the app:
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
import { registerBackgroundSync } from "expo-offline-sync";
|
|
294
|
+
|
|
295
|
+
// Call in root index.js / App.tsx
|
|
296
|
+
await registerBackgroundSync(syncClient, {
|
|
297
|
+
minimumInterval: 15 * 60, // 15 minutes (iOS minimum)
|
|
298
|
+
startOnBoot: true,
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## ⚙️ Advanced Customization
|
|
305
|
+
|
|
306
|
+
Every component of `expo-offline-sync` can be customized:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
const client = new OfflineSyncClient({
|
|
310
|
+
// 1. Custom Storage Adapter (defaults to SQLiteAdapter)
|
|
311
|
+
storage: new SQLiteAdapter({ databaseName: "my_app_offline.db" }),
|
|
312
|
+
|
|
313
|
+
// 2. Custom Retry Policy
|
|
314
|
+
retryPolicy: {
|
|
315
|
+
maxRetries: 5,
|
|
316
|
+
initialDelayMs: 1000,
|
|
317
|
+
maxDelayMs: 30000,
|
|
318
|
+
backoffFactor: 2,
|
|
319
|
+
jitter: true,
|
|
320
|
+
shouldRetry: (error, item) => {
|
|
321
|
+
// Don't retry 400 or 422 errors; retry 5xx and network errors
|
|
322
|
+
if (
|
|
323
|
+
error?.status >= 400 &&
|
|
324
|
+
error?.status < 500 &&
|
|
325
|
+
error?.status !== 401
|
|
326
|
+
) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
return true;
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
// 3. Concurrency
|
|
334
|
+
maxConcurrentScopes: 4, // Max different scopes executing in parallel
|
|
335
|
+
|
|
336
|
+
// 4. Global Request Lifecycle
|
|
337
|
+
onRequest: async (item) => {
|
|
338
|
+
console.log(`Executing mutation ${item.id} of type ${item.type}`);
|
|
339
|
+
},
|
|
340
|
+
onResponse: async (item, result) => {
|
|
341
|
+
console.log(`Mutation ${item.id} completed successfully`);
|
|
342
|
+
},
|
|
343
|
+
onRollback: async (item, error) => {
|
|
344
|
+
console.warn(`Mutation ${item.id} permanently failed:`, error.message);
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## 📊 Comparison
|
|
352
|
+
|
|
353
|
+
| Feature | `expo-offline-sync` | WatermelonDB | TanStack Query | redux-offline |
|
|
354
|
+
| :------------------------------- | :-----------------: | :--------------------: | :----------------: | :-------------: |
|
|
355
|
+
| **Setup Time** | **< 10 mins** | Days / Weeks | ~1 hour | ~2 hours |
|
|
356
|
+
| **Backend Changes** | **Zero** | Required Sync Protocol | Zero | Zero |
|
|
357
|
+
| **Works with Any State Manager** | ✅ | ❌ (Own ORM) | ❌ (TanStack only) | ❌ (Redux only) |
|
|
358
|
+
| **Survives App Restarts** | ✅ (Durable SQLite) | ✅ | ⚠️ Flaky Closures | ✅ |
|
|
359
|
+
| **Scoped FIFO (No Blockings)** | ✅ | ❌ | ❌ | ❌ |
|
|
360
|
+
| **Dynamic Token Refresh** | ✅ | ❌ | Custom code | ❌ |
|
|
361
|
+
| **Expo SDK 51/52+ Ready** | ✅ | Needs config | ✅ | ❌ Broken |
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## 📄 License
|
|
366
|
+
|
|
367
|
+
MIT © [mamicrose](https://github.com/mamicrose/expo-offline-sync/blob/main/LICENSE)
|