18d307f52SEvan Baconimport { getExpoHomeDirectory } from '@expo/config/build/getUserState'; 28d307f52SEvan Baconimport { JSONValue } from '@expo/json-file'; 38d307f52SEvan Baconimport fetchInstance from 'node-fetch'; 48d307f52SEvan Baconimport path from 'path'; 58d307f52SEvan Bacon 68d307f52SEvan Baconimport { FileSystemCache } from './cache/FileSystemCache'; 78d307f52SEvan Baconimport { wrapFetchWithCache } from './cache/wrapFetchWithCache'; 88d307f52SEvan Baconimport { FetchLike } from './client.types'; 98d307f52SEvan Baconimport { wrapFetchWithBaseUrl } from './wrapFetchWithBaseUrl'; 108d307f52SEvan Baconimport { wrapFetchWithOffline } from './wrapFetchWithOffline'; 1182f3de79SEvan Baconimport { wrapFetchWithProgress } from './wrapFetchWithProgress'; 128c8eefe0SEvan Baconimport { wrapFetchWithProxy } from './wrapFetchWithProxy'; 13*8a424bebSJames Ideimport { env } from '../../utils/env'; 14*8a424bebSJames Ideimport { CommandError } from '../../utils/errors'; 15*8a424bebSJames Ideimport { getExpoApiBaseUrl } from '../endpoint'; 16*8a424bebSJames Ideimport { disableNetwork } from '../settings'; 17*8a424bebSJames Ideimport UserSettings from '../user/UserSettings'; 188d307f52SEvan Bacon 198d307f52SEvan Baconexport class ApiV2Error extends Error { 208d307f52SEvan Bacon readonly name = 'ApiV2Error'; 215529feceSEvan Bacon readonly code: string; 228d307f52SEvan Bacon readonly expoApiV2ErrorCode: string; 238d307f52SEvan Bacon readonly expoApiV2ErrorDetails?: JSONValue; 248d307f52SEvan Bacon readonly expoApiV2ErrorServerStack?: string; 258d307f52SEvan Bacon readonly expoApiV2ErrorMetadata?: object; 268d307f52SEvan Bacon 278d307f52SEvan Bacon constructor(response: { 288d307f52SEvan Bacon message: string; 298d307f52SEvan Bacon code: string; 308d307f52SEvan Bacon stack?: string; 318d307f52SEvan Bacon details?: JSONValue; 328d307f52SEvan Bacon metadata?: object; 338d307f52SEvan Bacon }) { 348d307f52SEvan Bacon super(response.message); 355529feceSEvan Bacon this.code = response.code; 368d307f52SEvan Bacon this.expoApiV2ErrorCode = response.code; 378d307f52SEvan Bacon this.expoApiV2ErrorDetails = response.details; 388d307f52SEvan Bacon this.expoApiV2ErrorServerStack = response.stack; 398d307f52SEvan Bacon this.expoApiV2ErrorMetadata = response.metadata; 408d307f52SEvan Bacon } 418d307f52SEvan Bacon} 428d307f52SEvan Bacon 438d307f52SEvan Bacon/** 448d307f52SEvan Bacon * An Expo server error that didn't return the expected error JSON information. 458d307f52SEvan Bacon * The only 'expected' place for this is in testing, all other cases are bugs with the server. 468d307f52SEvan Bacon */ 478d307f52SEvan Baconexport class UnexpectedServerError extends Error { 488d307f52SEvan Bacon readonly name = 'UnexpectedServerError'; 498d307f52SEvan Bacon} 508d307f52SEvan Bacon 518d307f52SEvan Bacon/** 528d307f52SEvan Bacon * @returns a `fetch` function that will inject user authentication information and handle errors from the Expo API. 538d307f52SEvan Bacon */ 548d307f52SEvan Baconexport function wrapFetchWithCredentials(fetchFunction: FetchLike): FetchLike { 558d307f52SEvan Bacon return async function fetchWithCredentials(url, options = {}) { 568d307f52SEvan Bacon if (Array.isArray(options.headers)) { 578d307f52SEvan Bacon throw new Error('request headers must be in object form'); 588d307f52SEvan Bacon } 598d307f52SEvan Bacon 608d307f52SEvan Bacon const resolvedHeaders = options.headers ?? ({} as any); 618d307f52SEvan Bacon 628d307f52SEvan Bacon const token = UserSettings.getAccessToken(); 638d307f52SEvan Bacon if (token) { 648d307f52SEvan Bacon resolvedHeaders.authorization = `Bearer ${token}`; 658d307f52SEvan Bacon } else { 668d307f52SEvan Bacon const sessionSecret = UserSettings.getSession()?.sessionSecret; 678d307f52SEvan Bacon if (sessionSecret) { 688d307f52SEvan Bacon resolvedHeaders['expo-session'] = sessionSecret; 698d307f52SEvan Bacon } 708d307f52SEvan Bacon } 718d307f52SEvan Bacon 72e32ccf9fSEvan Bacon try { 738d307f52SEvan Bacon const results = await fetchFunction(url, { 748d307f52SEvan Bacon ...options, 758d307f52SEvan Bacon headers: resolvedHeaders, 768d307f52SEvan Bacon }); 778d307f52SEvan Bacon 788d307f52SEvan Bacon if (results.status >= 400 && results.status < 500) { 798d307f52SEvan Bacon const body = await results.text(); 808d307f52SEvan Bacon try { 818d307f52SEvan Bacon const data = JSON.parse(body); 828d307f52SEvan Bacon if (data?.errors?.length) { 838d307f52SEvan Bacon throw new ApiV2Error(data.errors[0]); 848d307f52SEvan Bacon } 8529975bfdSEvan Bacon } catch (error: any) { 868d307f52SEvan Bacon // Server returned non-json response. 878d307f52SEvan Bacon if (error.message.includes('in JSON at position')) { 888d307f52SEvan Bacon throw new UnexpectedServerError(body); 898d307f52SEvan Bacon } 908d307f52SEvan Bacon throw error; 918d307f52SEvan Bacon } 928d307f52SEvan Bacon } 938d307f52SEvan Bacon return results; 94e32ccf9fSEvan Bacon } catch (error: any) { 95e32ccf9fSEvan Bacon // Specifically, when running `npx expo start` and the wifi is connected but not really (public wifi, airplanes, etc). 96e32ccf9fSEvan Bacon if ('code' in error && error.code === 'ENOTFOUND') { 97e32ccf9fSEvan Bacon disableNetwork(); 98e32ccf9fSEvan Bacon 99e32ccf9fSEvan Bacon throw new CommandError( 100e32ccf9fSEvan Bacon 'OFFLINE', 101e32ccf9fSEvan Bacon 'Network connection is unreliable. Try again with the environment variable `EXPO_OFFLINE=1` to skip network requests.' 102e32ccf9fSEvan Bacon ); 103e32ccf9fSEvan Bacon } 104e32ccf9fSEvan Bacon 105e32ccf9fSEvan Bacon throw error; 106e32ccf9fSEvan Bacon } 1078d307f52SEvan Bacon }; 1088d307f52SEvan Bacon} 1098d307f52SEvan Bacon 1108d307f52SEvan Baconconst fetchWithOffline = wrapFetchWithOffline(fetchInstance); 1118d307f52SEvan Bacon 1128d307f52SEvan Baconconst fetchWithBaseUrl = wrapFetchWithBaseUrl(fetchWithOffline, getExpoApiBaseUrl() + '/v2/'); 1138d307f52SEvan Bacon 1148c8eefe0SEvan Baconconst fetchWithProxy = wrapFetchWithProxy(fetchWithBaseUrl); 1158c8eefe0SEvan Bacon 11682f3de79SEvan Baconconst fetchWithCredentials = wrapFetchWithProgress(wrapFetchWithCredentials(fetchWithProxy)); 1178d307f52SEvan Bacon 1188d307f52SEvan Bacon/** 1198d307f52SEvan Bacon * Create an instance of the fully qualified fetch command (auto authentication and api) but with caching in the '~/.expo' directory. 1208d307f52SEvan Bacon * Caching is disabled automatically if the EXPO_NO_CACHE or EXPO_BETA environment variables are enabled. 1218d307f52SEvan Bacon */ 1228d307f52SEvan Baconexport function createCachedFetch({ 12382f3de79SEvan Bacon fetch = fetchWithCredentials, 1248d307f52SEvan Bacon cacheDirectory, 1258d307f52SEvan Bacon ttl, 12609bb6093SEvan Bacon skipCache, 1278d307f52SEvan Bacon}: { 1288d307f52SEvan Bacon fetch?: FetchLike; 1298d307f52SEvan Bacon cacheDirectory: string; 1308d307f52SEvan Bacon ttl?: number; 13109bb6093SEvan Bacon skipCache?: boolean; 1328d307f52SEvan Bacon}): FetchLike { 1338d307f52SEvan Bacon // Disable all caching in EXPO_BETA. 134814b6fafSEvan Bacon if (skipCache || env.EXPO_BETA || env.EXPO_NO_CACHE) { 13582f3de79SEvan Bacon return fetch; 1368d307f52SEvan Bacon } 1378d307f52SEvan Bacon 1388d307f52SEvan Bacon return wrapFetchWithCache( 13982f3de79SEvan Bacon fetch, 1408d307f52SEvan Bacon new FileSystemCache({ 1418d307f52SEvan Bacon cacheDirectory: path.join(getExpoHomeDirectory(), cacheDirectory), 1428d307f52SEvan Bacon ttl, 1438d307f52SEvan Bacon }) 1448d307f52SEvan Bacon ); 1458d307f52SEvan Bacon} 1468d307f52SEvan Bacon 1478d307f52SEvan Bacon/** Instance of fetch with automatic base URL pointing to the Expo API, user credential injection, and API error handling. Caching not included. */ 14882f3de79SEvan Baconexport const fetchAsync = wrapFetchWithProgress(wrapFetchWithCredentials(fetchWithProxy)); 149