1import { getExpoHomeDirectory } from '@expo/config/build/getUserState'; 2import { JSONValue } from '@expo/json-file'; 3import fetchInstance from 'node-fetch'; 4import path from 'path'; 5 6import { env } from '../../utils/env'; 7import { CommandError } from '../../utils/errors'; 8import { getExpoApiBaseUrl } from '../endpoint'; 9import { disableNetwork } from '../settings'; 10import UserSettings from '../user/UserSettings'; 11import { FileSystemCache } from './cache/FileSystemCache'; 12import { wrapFetchWithCache } from './cache/wrapFetchWithCache'; 13import { FetchLike } from './client.types'; 14import { wrapFetchWithBaseUrl } from './wrapFetchWithBaseUrl'; 15import { wrapFetchWithOffline } from './wrapFetchWithOffline'; 16import { wrapFetchWithProgress } from './wrapFetchWithProgress'; 17import { wrapFetchWithProxy } from './wrapFetchWithProxy'; 18 19export class ApiV2Error extends Error { 20 readonly name = 'ApiV2Error'; 21 readonly code: string; 22 readonly expoApiV2ErrorCode: string; 23 readonly expoApiV2ErrorDetails?: JSONValue; 24 readonly expoApiV2ErrorServerStack?: string; 25 readonly expoApiV2ErrorMetadata?: object; 26 27 constructor(response: { 28 message: string; 29 code: string; 30 stack?: string; 31 details?: JSONValue; 32 metadata?: object; 33 }) { 34 super(response.message); 35 this.code = response.code; 36 this.expoApiV2ErrorCode = response.code; 37 this.expoApiV2ErrorDetails = response.details; 38 this.expoApiV2ErrorServerStack = response.stack; 39 this.expoApiV2ErrorMetadata = response.metadata; 40 } 41} 42 43/** 44 * An Expo server error that didn't return the expected error JSON information. 45 * The only 'expected' place for this is in testing, all other cases are bugs with the server. 46 */ 47export class UnexpectedServerError extends Error { 48 readonly name = 'UnexpectedServerError'; 49} 50 51/** 52 * @returns a `fetch` function that will inject user authentication information and handle errors from the Expo API. 53 */ 54export function wrapFetchWithCredentials(fetchFunction: FetchLike): FetchLike { 55 return async function fetchWithCredentials(url, options = {}) { 56 if (Array.isArray(options.headers)) { 57 throw new Error('request headers must be in object form'); 58 } 59 60 const resolvedHeaders = options.headers ?? ({} as any); 61 62 const token = UserSettings.getAccessToken(); 63 if (token) { 64 resolvedHeaders.authorization = `Bearer ${token}`; 65 } else { 66 const sessionSecret = UserSettings.getSession()?.sessionSecret; 67 if (sessionSecret) { 68 resolvedHeaders['expo-session'] = sessionSecret; 69 } 70 } 71 72 try { 73 const results = await fetchFunction(url, { 74 ...options, 75 headers: resolvedHeaders, 76 }); 77 78 if (results.status >= 400 && results.status < 500) { 79 const body = await results.text(); 80 try { 81 const data = JSON.parse(body); 82 if (data?.errors?.length) { 83 throw new ApiV2Error(data.errors[0]); 84 } 85 } catch (error: any) { 86 // Server returned non-json response. 87 if (error.message.includes('in JSON at position')) { 88 throw new UnexpectedServerError(body); 89 } 90 throw error; 91 } 92 } 93 return results; 94 } catch (error: any) { 95 // Specifically, when running `npx expo start` and the wifi is connected but not really (public wifi, airplanes, etc). 96 if ('code' in error && error.code === 'ENOTFOUND') { 97 disableNetwork(); 98 99 throw new CommandError( 100 'OFFLINE', 101 'Network connection is unreliable. Try again with the environment variable `EXPO_OFFLINE=1` to skip network requests.' 102 ); 103 } 104 105 throw error; 106 } 107 }; 108} 109 110const fetchWithOffline = wrapFetchWithOffline(fetchInstance); 111 112const fetchWithBaseUrl = wrapFetchWithBaseUrl(fetchWithOffline, getExpoApiBaseUrl() + '/v2/'); 113 114const fetchWithProxy = wrapFetchWithProxy(fetchWithBaseUrl); 115 116const fetchWithCredentials = wrapFetchWithProgress(wrapFetchWithCredentials(fetchWithProxy)); 117 118/** 119 * Create an instance of the fully qualified fetch command (auto authentication and api) but with caching in the '~/.expo' directory. 120 * Caching is disabled automatically if the EXPO_NO_CACHE or EXPO_BETA environment variables are enabled. 121 */ 122export function createCachedFetch({ 123 fetch = fetchWithCredentials, 124 cacheDirectory, 125 ttl, 126 skipCache, 127}: { 128 fetch?: FetchLike; 129 cacheDirectory: string; 130 ttl?: number; 131 skipCache?: boolean; 132}): FetchLike { 133 // Disable all caching in EXPO_BETA. 134 if (skipCache || env.EXPO_BETA || env.EXPO_NO_CACHE) { 135 return fetch; 136 } 137 138 return wrapFetchWithCache( 139 fetch, 140 new FileSystemCache({ 141 cacheDirectory: path.join(getExpoHomeDirectory(), cacheDirectory), 142 ttl, 143 }) 144 ); 145} 146 147/** Instance of fetch with automatic base URL pointing to the Expo API, user credential injection, and API error handling. Caching not included. */ 148export const fetchAsync = wrapFetchWithProgress(wrapFetchWithCredentials(fetchWithProxy)); 149