├── src ├── index.js └── OfflineLink.js ├── package.json ├── index.d.ts ├── LICENSE └── README.md /src/index.js: -------------------------------------------------------------------------------- 1 | export { default, syncStatusQuery } from "./OfflineLink"; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-link-offline", 3 | "version": "1.1.1", 4 | "description": "An Apollo Link to queue mutations when offline or network errors exist.", 5 | "repository": "github:carvajalconsultants/apollo-link-offline", 6 | "main": "./src/index.js", 7 | "types": "index.d.ts", 8 | "author": "Miguel Carvajal ", 9 | "license": "MIT", 10 | "peerDependencies": { 11 | "@apollo/client": "^3.7.14", 12 | "lodash": "^4.17.15", 13 | "uuid": "^9.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'apollo-link-offline' { 2 | import type { ApolloLink, NextLink, Operation, gql } from '@apollo/client'; 3 | import type { AsyncStorageStatic } from '@react-native-community/async-storage'; 4 | 5 | type Options = Partial<{ 6 | storage: AsyncStorageStatic; 7 | retryInterval: number; 8 | sequential: boolean; 9 | retryOnServerError: boolean; 10 | }>; 11 | 12 | export const syncStatusQuery: ReturnType; 13 | 14 | export default class OfflineLink extends ApolloLink { 15 | constructor(options: Options); 16 | 17 | request(operation: Operation, forward: NextLink): any; 18 | 19 | migrate(): Promise; 20 | 21 | getQueue(): Promise>>; 22 | 23 | saveQueue(attemptId: string, item: Record): void; 24 | 25 | updateStatus(inflight: boolean): void; 26 | 27 | add(item): string; 28 | 29 | remove(itemId: string): void; 30 | 31 | sync(): Promise; 32 | 33 | setup(client: any): Promise; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Carvajal Consultants, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apollo-link-offline ![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg) ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) 2 | An Apollo Link to queue mutations when offline or network errors exist. 3 | 4 | Biggest different between this module and other offline modules available is that this module assumes the worst. It assumes the request will not reach the server and 5 | queues all mutations, responds with optimistic response and removes the mutation from the queue when the server responds success. 6 | 7 | Reason for this assumption is twofold: 8 | 1. Speed, since all mutations have optimistic response, the UI feels much snappier (like a local app) 9 | 2. In cases where the the network is NOT offline but really slow (think 2G in a third world country) and the request doesn't reach the server anyway, 10 | our queue retries until the server responds with success. 11 | 12 | Tested with Apollo version 3.7.14 13 | 14 | ### Setup 15 | 16 | ```bash 17 | yarn add apollo-link-offline 18 | ``` 19 | 20 | ### Example 21 | #### React Native 22 | 23 | ```javascript 24 | import React, { useState, useEffect } from "react"; 25 | import { AsyncStorage } from "react-native"; 26 | import { ApolloClient, ApolloLink, InMemoryCache } from "apollo-link"; 27 | import OfflineLink from "apollo-link-offline"; 28 | import { persistCache } from "apollo-cache-persist"; 29 | 30 | export default function App() { 31 | const [client, setClient] = useState(undefined); 32 | 33 | useEffect(() => { 34 | const serverLink = new HttpLink({ 35 | ... Your regular HttpLink options here ... 36 | }); 37 | 38 | const offlineLink = new OfflineLink({ 39 | storage: AsyncStorage 40 | }); 41 | 42 | const cache = new InMemoryCache(); 43 | 44 | const client = new ApolloClient({ 45 | link: ApolloLink.from([offlineLink, serverLink]), 46 | cache, 47 | defaultOptions: { 48 | watchQuery: { 49 | fetchPolicy: "cache-and-network", 50 | errorPolicy: "all", 51 | }, 52 | query: { 53 | fetchPolicy: "cache-and-network", 54 | errorPolicy: "all", 55 | }, 56 | mutate: { 57 | errorPolicy: "all" 58 | } 59 | } 60 | }); 61 | 62 | persistCache({ 63 | cache, 64 | storage: AsyncStorage, 65 | maxSize: false, 66 | debug: false 67 | }).then(() => { 68 | setClient(client); 69 | }); 70 | 71 | offlineLink.setup(client); 72 | 73 | return () => {}; 74 | }, []); 75 | 76 | if (client === undefined) return Loading...; 77 | 78 | return ( 79 | 80 | ... Your components here, make sure to add optimistic response to your mutations ... 81 | 82 | ); 83 | } 84 | ``` 85 | -------------------------------------------------------------------------------- /src/OfflineLink.js: -------------------------------------------------------------------------------- 1 | import { ApolloLink, Observable, gql } from "@apollo/client"; 2 | import debounce from "lodash/debounce"; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | const syncStatusQuery = gql` 6 | query syncStatus { 7 | mutations 8 | inflight 9 | } 10 | `; 11 | 12 | export default class OfflineLink extends ApolloLink { 13 | /** 14 | * storage 15 | * Provider that will persist the mutation queue. This can be any AsyncStorage compatible storage instance. 16 | * 17 | * retryInterval 18 | * Milliseconds between attempts to retry failed mutations. Defaults to 30,000 milliseconds. 19 | * 20 | * sequential 21 | * Indicates if the attempts should be retried in order. Defaults to false which retries all failed mutations in parallel. 22 | * 23 | * retryOnServerError 24 | * Indicates if mutations should be reattempted if there are server side errors, useful to retry mutations on session expiration. Defaults to false. 25 | */ 26 | constructor({ storage, retryInterval = 30000, sequential = false, retryOnServerError = false }) { 27 | super(); 28 | 29 | if (!storage) { 30 | throw new Error("Storage is required, it can be an AsyncStorage compatible storage instance."); 31 | } 32 | 33 | this.storage = storage; 34 | this.sequential = sequential; 35 | this.retryOnServerError = retryOnServerError; 36 | this.queue = new Map(); 37 | this.delayedSync = debounce(this.sync, retryInterval); 38 | this.prefix = 'offlineLink'; 39 | } 40 | 41 | request(operation, forward) { 42 | const me = this, 43 | context = operation.getContext(), 44 | { query, variables } = operation || {}; 45 | 46 | if (!context.optimisticResponse) { 47 | // If the mutation does not have an optimistic response then we don't defer it 48 | return forward(operation); 49 | } 50 | 51 | return new Observable(observer => { 52 | const attemptId = this.add({ 53 | mutation: query, variables, optimisticResponse: 54 | typeof context?.optimisticResponse === 'function' 55 | ? context.optimisticResponse(variables) 56 | : context.optimisticResponse, 57 | }); 58 | 59 | const subscription = forward(operation).subscribe({ 60 | next: result => { 61 | // Mutation was successful so we remove it from the queue since we don't need to retry it later 62 | this.remove(attemptId); 63 | 64 | observer.next(result); 65 | }, 66 | 67 | error: async networkError => { 68 | // Mutation failed so we try again after a certain amount of time. 69 | this.delayedSync(); 70 | 71 | // Resolve the mutation with the optimistic response so the UI can be updated 72 | observer.next({ 73 | data: typeof context.optimisticResponse === 'function' 74 | ? context.optimisticResponse(variables) 75 | : context.optimisticResponse, 76 | dataPresent: true, 77 | errors: [] 78 | }); 79 | 80 | // Say we're all done so the UI is re-rendered. 81 | observer.complete(); 82 | }, 83 | 84 | complete: () => observer.complete() 85 | }); 86 | 87 | return () => { 88 | subscription.unsubscribe(); 89 | }; 90 | }); 91 | } 92 | 93 | /** 94 | * If there exists the '@offlineLink' old file, migrate it into new multiple files 95 | */ 96 | migrate() { 97 | let map; 98 | return this.storage.getItem("@offlineLink") 99 | .then(stored => { 100 | 101 | if (stored) { 102 | map = new Map(JSON.parse(stored)); 103 | 104 | // Saving new version files 105 | map.forEach((value, key) => { 106 | this.storage.setItem(this.prefix + key, JSON.stringify(value)); 107 | }); 108 | 109 | // Saving new mutation Ids 110 | this.storage.setItem(this.prefix + "AttemptIds", [...map.keys()].join()); 111 | 112 | // remove old version file 113 | this.storage.removeItem("@offlineLink"); 114 | } 115 | }) 116 | .catch(err => { 117 | // Most likely happens the first time a mutation attempt is being persisted. 118 | }); 119 | } 120 | 121 | /** 122 | * Obtains the queue of mutations that must be sent to the server. 123 | * These are kept in a Map to preserve the order of the mutations in the queue. 124 | */ 125 | async getQueue() { 126 | let storedAttemptIds = [], 127 | map; 128 | 129 | await this.migrate(); 130 | 131 | return new Promise((resolve, reject) => { 132 | // Get all attempt Ids 133 | this.storage.getItem(this.prefix + "AttemptIds").then(storedIds => { 134 | map = new Map(); 135 | 136 | if (storedIds) { 137 | storedAttemptIds = storedIds.split(","); 138 | 139 | storedAttemptIds.forEach((storedId, index) => { 140 | 141 | // Get file of name '' 142 | this.storage.getItem(this.prefix + storedId).then(stored => { 143 | map.set(storedId, JSON.parse(stored)); 144 | 145 | // We return the map 146 | if (index === storedAttemptIds.length - 1) { 147 | resolve(map); 148 | } 149 | }); 150 | }); 151 | } else { 152 | resolve(map); 153 | } 154 | }) 155 | .catch(err => { 156 | // Most likely happens the first time a mutation attempt is being persisted. 157 | resolve(new Map()); 158 | }); 159 | }); 160 | } 161 | 162 | /** 163 | * Persist the queue so mutations can be retried at a later point in time. 164 | */ 165 | saveQueue(attemptId, item) { 166 | if (attemptId && item) { 167 | this.storage.setItem(this.prefix + attemptId, JSON.stringify(item)); 168 | } 169 | 170 | // Saving Ids file 171 | this.storage.setItem(this.prefix + "AttemptIds", [...this.queue.keys()].join()); 172 | 173 | this.updateStatus(false); 174 | } 175 | 176 | /** 177 | * Updates a SyncStatus object in the Apollo Cache so that the queue status can be obtained and dynamically updated. 178 | */ 179 | updateStatus(inflight) { 180 | this.client.writeQuery({ 181 | query: syncStatusQuery, data: { 182 | __typename: "SyncStatus", 183 | mutations: this.queue.size, 184 | inflight 185 | } 186 | }); 187 | } 188 | 189 | /** 190 | * Add a mutation attempt to the queue so that it can be retried at a later point in time. 191 | */ 192 | add(item) { 193 | // We give the mutation attempt a random id so that it is easy to remove when needed (in sync loop) 194 | const attemptId = uuidv4(); 195 | 196 | this.queue.set(attemptId, item); 197 | 198 | this.saveQueue(attemptId, item); 199 | 200 | return attemptId; 201 | } 202 | 203 | /** 204 | * Remove a mutation attempt from the queue. 205 | */ 206 | remove(attemptId) { 207 | this.queue.delete(attemptId); 208 | 209 | this.storage.removeItem(this.prefix + attemptId) 210 | 211 | this.saveQueue(); 212 | } 213 | 214 | /** 215 | * Takes the mutations in the queue and try to send them to the server again. 216 | */ 217 | async sync() { 218 | const queue = this.queue; 219 | 220 | if (queue.size < 1) { 221 | // There's nothing in the queue to sync, no reason to continue. 222 | 223 | return; 224 | } 225 | 226 | // Update the status to be "in progress" 227 | this.updateStatus(true); 228 | 229 | // Retry the mutations in the queue, the successful ones are removed from the queue 230 | if (this.sequential) { 231 | // Retry the mutations in the order in which they were originally executed 232 | 233 | const attempts = Array.from(queue); 234 | 235 | for (const [attemptId, attempt] of attempts) { 236 | const success = await this.client 237 | .mutate({ ...attempt, optimisticResponse: undefined }) 238 | .then(() => { 239 | // Mutation was successfully executed so we remove it from the queue 240 | 241 | this.remove(attemptId) 242 | return true; 243 | }) 244 | .catch(err => { 245 | if (this.retryOnServerError === false && err.networkError.response) { 246 | // There are GraphQL errors, which means the server processed the request so we can remove the mutation from the queue 247 | 248 | this.remove(attemptId) 249 | 250 | return true; 251 | } else { 252 | // There was a network error so we have to retry the mutation 253 | 254 | return false; 255 | } 256 | }) 257 | ; 258 | 259 | if (!success) { 260 | // The last mutation failed so we don't attempt any more 261 | break; 262 | } 263 | } 264 | } else { 265 | // Retry mutations in parallel 266 | 267 | await Promise.all(Array.from(queue).map(([attemptId, attempt]) => { 268 | return this.client.mutate(attempt) 269 | // Mutation was successfully executed so we remove it from the queue 270 | .then(() => { 271 | this.remove(attemptId) 272 | }) 273 | 274 | .catch(err => { 275 | if (this.retryOnServerError === false && err.networkError.response) { 276 | // There are GraphQL errors, which means the server processed the request so we can remove the mutation from the queue 277 | 278 | this.remove(attemptId) 279 | } 280 | }) 281 | ; 282 | })); 283 | } 284 | 285 | // Remaining mutations in the queue are persisted 286 | this.saveQueue(); 287 | 288 | if (queue.size > 0) { 289 | // If there are any mutations left in the queue, we retry them at a later point in time 290 | this.delayedSync(); 291 | } 292 | } 293 | 294 | /** 295 | * Configure the link to use Apollo Client and immediately try to sync the queue (if there's anything there). 296 | */ 297 | async setup(client) { 298 | this.client = client; 299 | this.queue = await this.getQueue(); 300 | 301 | return this.sync(); 302 | } 303 | } 304 | 305 | export { syncStatusQuery }; 306 | --------------------------------------------------------------------------------