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