├── .npmignore ├── .prettierrc ├── .gitignore ├── src ├── index.ts ├── testUtils │ └── utils.ts ├── handler │ └── cacheFirstHandler.ts ├── NetworkErrorLink.ts └── __tests__ │ ├── cacheFirstHandler.test.ts │ └── NetworkErrorLink.test.ts ├── tsconfig.json ├── rollup.config.js ├── .github └── workflows │ └── npm-publish.yml ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled 2 | dist 3 | lib 4 | 5 | .idea 6 | .vscode 7 | 8 | .DS_Store 9 | 10 | # Dependency directories 11 | node_modules 12 | 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | 18 | coverage -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { cacheFirstHandler, ICacheShape } from './handler/cacheFirstHandler' 2 | import { NetworkErrorLink } from './NetworkErrorLink' 3 | 4 | const cacheFirstNetworkErrorLink = (cache: ICacheShape): NetworkErrorLink => 5 | new NetworkErrorLink(cacheFirstHandler(cache)) 6 | 7 | export { NetworkErrorLink, cacheFirstNetworkErrorLink } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "lib", 5 | "outDir": "lib", 6 | "allowJs": true, 7 | "sourceMap": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "isolatedModules": true, 11 | "jsx": "react", 12 | "lib": ["es6"], 13 | "moduleResolution": "node", 14 | "strict": true, 15 | "noImplicitAny": false, 16 | "target": "esnext", 17 | "skipLibCheck": true 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["node_modules", "**/*.test.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2' 2 | import { terser } from 'rollup-plugin-terser' 3 | import pkg from './package.json' 4 | 5 | let defaults = { compilerOptions: { declaration: true } } 6 | let override = { compilerOptions: { declaration: false } } 7 | 8 | export default { 9 | input: 'src/index.ts', 10 | output: { 11 | file: 'lib/index.js', 12 | format: 'cjs', 13 | }, 14 | plugins: [ 15 | typescript({ 16 | typescript: require('typescript'), 17 | tsconfigDefaults: defaults, 18 | tsconfig: 'tsconfig.json', 19 | tsconfigOverride: override, 20 | }), 21 | terser(), // minifies generated bundles 22 | ], 23 | external: [...Object.keys(pkg.dependencies || {})], 24 | } 25 | -------------------------------------------------------------------------------- /src/testUtils/utils.ts: -------------------------------------------------------------------------------- 1 | import { ApolloLink, Observable, execute } from '@apollo/client/core'; 2 | import { NetworkErrorLink, NetworkErrorHandler } from '../NetworkErrorLink' 3 | import gql from 'graphql-tag' 4 | 5 | export const FAKE_QUERY = gql` 6 | query FakeQuery { 7 | stub { 8 | id 9 | } 10 | } 11 | ` 12 | 13 | const createNetworkErrorLink = (handler: NetworkErrorHandler) => { 14 | const nextLink = new ApolloLink( 15 | () => 16 | new Observable(observer => { 17 | observer.error(Error('network error')) 18 | }) 19 | ) 20 | const errorLink = new NetworkErrorLink(handler) 21 | return errorLink.concat(nextLink) 22 | } 23 | 24 | export const getFakeNetworkErrorLink = (handler: NetworkErrorHandler) => { 25 | const link = createNetworkErrorLink(handler) 26 | return execute(link, { query: FAKE_QUERY }) 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 14 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: 14 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | -------------------------------------------------------------------------------- /src/handler/cacheFirstHandler.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from '@apollo/client/core'; 2 | import { INetworkResponse } from '../NetworkErrorLink' 3 | 4 | interface Query { 5 | query: DocumentNode 6 | variables?: TVariables 7 | } 8 | 9 | interface ReadOptions extends Query { 10 | rootId?: string 11 | previousResult?: any 12 | } 13 | 14 | interface WriteOptions 15 | extends Query { 16 | dataId: string 17 | result: TResult 18 | } 19 | 20 | export interface ICacheShape { 21 | read(query: ReadOptions): T | null 22 | write( 23 | write: WriteOptions 24 | ): void 25 | } 26 | 27 | export const cacheFirstHandler = (cache: ICacheShape) => ({ 28 | networkError: error, 29 | operation, 30 | }: INetworkResponse) => { 31 | if (!operation.getContext().__skipErrorAccordingCache__) { 32 | throw error 33 | } 34 | 35 | const result = cache.read({ 36 | query: operation.query, 37 | variables: operation.variables, 38 | }) 39 | 40 | if (!result) { 41 | throw error 42 | } 43 | 44 | return result 45 | } 46 | -------------------------------------------------------------------------------- /src/NetworkErrorLink.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloLink, 3 | FetchResult, 4 | NextLink, 5 | Observable, 6 | Operation, 7 | } from '@apollo/client/core' 8 | 9 | export interface INetworkResponse { 10 | networkError: any 11 | operation: Operation 12 | forward?: NextLink 13 | } 14 | 15 | export type ResultData = { 16 | [key: string]: any 17 | } 18 | 19 | export type NetworkErrorHandler = ( 20 | error: INetworkResponse 21 | ) => ResultData | Promise 22 | 23 | export class NetworkErrorLink extends ApolloLink { 24 | private handler: NetworkErrorHandler 25 | constructor(errorHandler: NetworkErrorHandler) { 26 | super() 27 | this.handler = errorHandler 28 | } 29 | 30 | request( 31 | operation: Operation, 32 | forward?: NextLink 33 | ): Observable | null { 34 | if (!forward) return null 35 | 36 | return new Observable(observer => { 37 | const subscription = forward(operation).subscribe({ 38 | next: result => observer.next(result), 39 | error: networkError => { 40 | const errorData = { 41 | networkError, 42 | operation, 43 | forward, 44 | } 45 | 46 | Promise.resolve(errorData) 47 | .then(this.handler) 48 | .then(data => { 49 | observer.next({ data }) 50 | observer.complete() 51 | }) 52 | .catch(error => observer.error(error || networkError)) 53 | }, 54 | complete: observer.complete.bind(observer), 55 | }) 56 | 57 | return () => { 58 | if (subscription) subscription.unsubscribe() 59 | } 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/__tests__/cacheFirstHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { cacheFirstHandler } from '../handler/cacheFirstHandler' 2 | import { NetworkErrorHandler } from '../NetworkErrorLink' 3 | import { FAKE_QUERY } from '../testUtils/utils' 4 | 5 | describe('cacheFirstHandler', () => { 6 | const mockCache = { 7 | read: jest.fn(), 8 | write: jest.fn(), 9 | } 10 | const networkError = Error('network error') 11 | let handler: NetworkErrorHandler 12 | beforeEach(() => { 13 | handler = cacheFirstHandler(mockCache) 14 | }) 15 | 16 | it('should throw Error if not enable __skipErrorAccordingCache__ flag when have local cache data', () => { 17 | mockCache.read.mockReturnValue({ user: 'holly' }) 18 | const t = () => 19 | handler({ 20 | networkError, 21 | operation: { 22 | query: FAKE_QUERY, 23 | getContext() { 24 | return {} 25 | }, 26 | } as any, 27 | }) 28 | 29 | expect(t).toThrowError(networkError) 30 | }) 31 | 32 | it('should return data if enable __skipErrorAccordingCache__ flag when have local cache data', () => { 33 | const data = { user: 'holly' } 34 | mockCache.read.mockReturnValue(data) 35 | const result = handler({ 36 | networkError, 37 | operation: { 38 | query: FAKE_QUERY, 39 | getContext() { 40 | return { 41 | __skipErrorAccordingCache__: true, 42 | } 43 | }, 44 | } as any, 45 | }) 46 | 47 | expect(result).toEqual(data) 48 | }) 49 | 50 | it('should throw error if enable __skipErrorAccordingCache__ flag when no local cache data', () => { 51 | mockCache.read.mockReturnValue(null) 52 | const t = () => 53 | handler({ 54 | networkError, 55 | operation: { 56 | query: FAKE_QUERY, 57 | getContext() { 58 | return { 59 | __skipErrorAccordingCache__: true, 60 | } 61 | }, 62 | } as any, 63 | }) 64 | 65 | expect(t).toThrowError(networkError) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-link-network-error", 3 | "version": "0.0.5", 4 | "description": "An Apollo Link that you can dynamic ignore/change the network error.", 5 | "main": "./lib/index.js", 6 | "jsnext:main": "./lib/index.js", 7 | "typings": "./lib/index.d.ts", 8 | "private": false, 9 | "engines": { 10 | "node": ">=8", 11 | "npm": ">=5" 12 | }, 13 | "peerDependencies": { 14 | "graphql": "0.11.7 || ^0.12.0 || ^0.13.0 || >=14.0.0" 15 | }, 16 | "scripts": { 17 | "build": "tsc -p .", 18 | "bundle": "rollup -c", 19 | "commit": "git-cz", 20 | "clean": "rimraf lib/*", 21 | "prebuild": "npm run clean", 22 | "postbuild": "npm run bundle", 23 | "prepublish": "npm run clean && npm run build", 24 | "test": "jest" 25 | }, 26 | "keywords": [ 27 | "apollo", 28 | "apollo-link", 29 | "offline", 30 | "network", 31 | "error", 32 | "ignore" 33 | ], 34 | "author": "hollyoops ", 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/hollyoops/apollo-link-network-error.git" 38 | }, 39 | "license": "MIT", 40 | "dependencies": { 41 | "@apollo/client": "^3.3.21" 42 | }, 43 | "devDependencies": { 44 | "@types/jest": "^25.1.1", 45 | "git-cz": "^4.2.0", 46 | "graphql": "^14.6.0", 47 | "graphql-tag": "^2.10.1", 48 | "jest": "^25.1.0", 49 | "prettier": "^1.19.1", 50 | "rimraf": "^3.0.1", 51 | "rollup": "^1.31.0", 52 | "rollup-plugin-terser": "^5.2.0", 53 | "rollup-plugin-typescript2": "^0.25.3", 54 | "ts-jest": "^25.1.0", 55 | "typescript": "^3.7.5" 56 | }, 57 | "jest": { 58 | "transform": { 59 | "^.+\\.tsx?$": "ts-jest" 60 | }, 61 | "testRegex": "(/__tests__/.*)\\.(test|spec)\\.[jt]sx?$", 62 | "timers": "fake", 63 | "moduleFileExtensions": [ 64 | "ts", 65 | "tsx", 66 | "js", 67 | "jsx" 68 | ], 69 | "coverageReporters": [ 70 | "text", 71 | "lcov" 72 | ], 73 | "collectCoverageFrom": [ 74 | "src/**/*.{ts,tsx}", 75 | "!src/testUtils/*", 76 | "!src/index.ts" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/__tests__/NetworkErrorLink.test.ts: -------------------------------------------------------------------------------- 1 | import { getFakeNetworkErrorLink } from '../testUtils/utils' 2 | import { NetworkErrorHandler, NetworkErrorLink } from '../NetworkErrorLink' 3 | 4 | describe('NetworkErrorLink', () => { 5 | const assertError = (done: jest.DoneCallback) => ( 6 | handler: NetworkErrorHandler, 7 | error: Error 8 | ) => { 9 | getFakeNetworkErrorLink(handler).subscribe({ 10 | error: networkError => { 11 | expect(networkError.toString()).toEqual(error.toString()) 12 | done() 13 | }, 14 | }) 15 | 16 | jest.runAllTimers() 17 | } 18 | 19 | const assertNext = (done: jest.DoneCallback) => ( 20 | handler: NetworkErrorHandler, 21 | customerData: any 22 | ) => { 23 | getFakeNetworkErrorLink(handler).subscribe({ 24 | next: data => { 25 | expect(data).toEqual({ data: customerData }) 26 | done() 27 | }, 28 | }) 29 | 30 | jest.runAllTimers() 31 | } 32 | 33 | it('should return null when call request and there no next link', () => { 34 | const handler = () => ({}) 35 | const link = new NetworkErrorLink(handler) 36 | expect(link.request({} as any, undefined)).toBe(null) 37 | }) 38 | 39 | it('should return customer error when error handler return a customer error', done => { 40 | const customerError = Error('customer error') 41 | const handler = () => { 42 | throw customerError 43 | } 44 | assertError(done)(handler, customerError) 45 | }) 46 | 47 | it('should return customer error when error handler return a customer error by async', done => { 48 | const customerError = Error('customer error') 49 | const handler = () => Promise.reject(customerError) 50 | assertError(done)(handler, customerError) 51 | }) 52 | 53 | it('should return response when error handler return an obj', done => { 54 | const customerData = { user: 'holly' } 55 | const handler = () => customerData 56 | assertNext(done)(handler, customerData) 57 | }) 58 | 59 | it('should return response when error handler return an obj by async', done => { 60 | const customerData = { user: 'holly' } 61 | const handler = () => 62 | new Promise((resolve, _) => { 63 | setTimeout(() => { 64 | resolve(customerData) 65 | }, 300) 66 | jest.runAllTimers() 67 | }) 68 | assertNext(done)(handler, customerData) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apollo-link-network-error 2 | 3 | An Apollo Link that you can dynamic ignore/change the network error. and you can easy to implement the **Network-first feature**(with `network-only` _fetchPolicy_) and **Offline-first feature** (with `cache-and-network` _fetchPolicy_) 4 | 5 | Basically this means your UI's queries will always work if the requested data is available in the local cache and it will always keep the cached data consistent with your server data if it can be reached. 6 | 7 | > NOTE: Currently we only tested it on react-native. If there are some issues on browser, just create an issue or make a PR 8 | 9 | ## Install 10 | 11 | ```shell 12 | npm install --save apollo-link-network-error 13 | ``` 14 | 15 | ## Basic Usage 16 | 17 | ### Setup 18 | 19 | ```javascript 20 | import { ApolloClient } from 'apollo-client' 21 | import { NetworkErrorLink } from 'apollo-link-network-error' 22 | 23 | const onNetworkError = ({ error, operation }) => { 24 | if ('some_condition_1') { 25 | // option1: throw a new error to replace 26 | throw error 27 | } 28 | 29 | if ('some_condition_2') { 30 | // option2: not return error and will take following object as response 31 | return { 32 | info: somedata, 33 | } 34 | } 35 | 36 | return Promise(....) // option3: you can return a promise 37 | } 38 | 39 | const errorIgnoreLink = new NetworkErrorLink(onNetworkError) 40 | const client = new ApolloClient({ 41 | cache, 42 | errorIgnoreLink, 43 | }) 44 | ``` 45 | 46 | ## Offline solution 47 | 48 | ### Setup 49 | 50 | ```javascript 51 | import { ApolloClient, ApolloLink } from '@apollo/client' 52 | import { cacheFirstNetworkErrorLink } from 'apollo-link-network-error' 53 | 54 | const cache = new InMemoryCache() 55 | // Create the cache first network error link 56 | const errorIgnoreLink = cacheFirstNetworkErrorLink(cache) 57 | const link = ApolloLink.from([errorLink, errorIgnoreLink, httpLink]) 58 | const client = new ApolloClient({ 59 | cache, 60 | link, 61 | }) 62 | 63 | persistCache({ cache, storage: AsyncStorage }) 64 | ``` 65 | 66 | _Note: Once set up, you can add the flag in context (i.e. `context: { __skipErrorAccordingCache__: true }`)._ 67 | 68 | ### Network-first Example 69 | 70 | > NOTE: As all we know, apollo client provide the `fetchPolicy: 'network-only'`, this policy will try visit remote data, **if success, it will store the data to cache** 71 | 72 | **`'network-only' + cacheFirstNetworkErrorLink`**: This a kind of like the network-first behavior. it will try to visit network data first. **if the server can't be reached or no network**, it will use the cache data. if no cache data, it will throw a network error 73 | 74 | ```javascript 75 | // Note: You can not use 'cache-and-network' on client.query(). this is the limitation from apollo. 76 | client.query({ 77 | fetchPolicy: 'network-only', 78 | query: /* Your query here */, 79 | // Enable error ignore 80 | context: { __skipErrorAccordingCache__: true } 81 | variables: /* Your variables here */ 82 | }); 83 | 84 | ``` 85 | 86 | #### React-hook 87 | 88 | ```javascript 89 | const { data, error } = useQuery(YOUR_QUERY, { 90 | variables: YOUR_VARIBLES, 91 | fetchPolicy: 'network-only', 92 | context: { __skipErrorAccordingCache__: true }, 93 | }) 94 | ``` 95 | 96 | ### Offline-first Example 97 | 98 | > NOTE: As all we know, apollo client provide the `fetchPolicy: 'cache-and-network'`. It will use local data and then try to fetch the network data. However, it errors if the server can't be reached or no network connection 99 | 100 | **`'cache-and-network' + cacheFirstNetworkErrorLink`**: **if the server can't be reached or no network**, it still try to use cache data. if no cached data, it will throw a network error. 101 | 102 | #### React-Apollo 103 | 104 | ```javascript 105 | 111 | {props.children} 112 | 113 | ``` 114 | 115 | #### React-hook 116 | 117 | ```javascript 118 | const { data, error } = useQuery(YOUR_QUERY, { 119 | variables: YOUR_VARIBLES, 120 | fetchPolicy: 'cache-and-network', 121 | context: { __skipErrorAccordingCache__: true }, 122 | }) 123 | ``` 124 | --------------------------------------------------------------------------------