xref: /expo/packages/@expo/cli/src/api/rest/client.ts (revision a9c42bb2)
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