diff --git a/lib/utils/fetch-json.js b/lib/utils/fetch-json.js index ce85bbf56..108d0a7dc 100644 --- a/lib/utils/fetch-json.js +++ b/lib/utils/fetch-json.js @@ -1,206 +1,204 @@ // @flow -import _cloneDeep from 'lodash/fp/cloneDeep'; - import { fetchJSONTimeout } from '../shared/timeouts'; import { SocketOffline, SocketTimeout } from '../socket/inflight-requests'; import type { Shape } from '../types/core'; import { type Endpoint, type SocketAPIHandler, endpointIsSocketPreferred, endpointIsSocketOnly, } from '../types/endpoints'; import type { ServerSessionChange, ClientSessionChange, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; import { getConfig } from './config'; import { ServerError, FetchTimeout } from './errors'; import sleep from './sleep'; import { uploadBlob, type UploadBlob } from './upload-blob'; export type FetchJSONOptions = Shape<{| // null timeout means no timeout, which is the default for uploadBlob +timeout: ?number, // in milliseconds // getResultInfo will be called right before fetchJSON successfully resolves // and includes additional information about the request +getResultInfo: (resultInfo: FetchResultInfo) => mixed, +blobUpload: boolean | UploadBlob, // the rest (onProgress, abortHandler) only work with blobUpload +onProgress: (percent: number) => void, // abortHandler will receive an abort function once the upload starts +abortHandler: (abort: () => void) => void, |}>; export type FetchResultInfoInterface = 'socket' | 'REST'; export type FetchResultInfo = {| +interface: FetchResultInfoInterface, |}; export type FetchJSONServerResponse = Shape<{| +cookieChange: ServerSessionChange, +currentUserInfo: CurrentUserInfo, +error: string, +payload: Object, |}>; // You'll notice that this is not the type of the fetchJSON function below. This // is because the first several parameters to that functon get bound in by the // helpers in lib/utils/action-utils.js. This type represents the form of the // fetchJSON function that gets passed to the action function in lib/actions. export type FetchJSON = ( endpoint: Endpoint, input: Object, options?: ?FetchJSONOptions, ) => Promise; type RequestData = {| input: { [key: string]: mixed }, cookie?: ?string, sessionID?: ?string, |}; // If cookie is undefined, then we will defer to the underlying environment to // handle cookies, and we won't worry about them. We do this on the web since // our cookies are httponly to protect against XSS attacks. On the other hand, // on native we want to keep track of the cookies since we don't trust the // underlying implementations and prefer for things to be explicit, and XSS // isn't a thing on native. Note that for native, cookie might be null // (indicating we don't have one), and we will then set an empty Cookie header. async function fetchJSON( cookie: ?string, setNewSession: (sessionChange: ClientSessionChange, error: ?string) => void, waitIfCookieInvalidated: () => Promise, cookieInvalidationRecovery: ( sessionChange: ClientSessionChange, ) => Promise, urlPrefix: string, sessionID: ?string, connectionStatus: ConnectionStatus, socketAPIHandler: ?SocketAPIHandler, endpoint: Endpoint, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ) { const possibleReplacement = await waitIfCookieInvalidated(); if (possibleReplacement) { return await possibleReplacement(endpoint, input, options); } if ( endpointIsSocketPreferred(endpoint) && connectionStatus === 'connected' && socketAPIHandler ) { try { const result = await socketAPIHandler({ endpoint, input }); options?.getResultInfo?.({ interface: 'socket' }); return result; } catch (e) { if (endpointIsSocketOnly(endpoint)) { throw e; } else if (e instanceof SocketOffline) { // nothing } else if (e instanceof SocketTimeout) { // nothing } else { throw e; } } } if (endpointIsSocketOnly(endpoint)) { throw new SocketOffline('socket_offline'); } const url = urlPrefix ? `${urlPrefix}/${endpoint}` : endpoint; let json; if (options && options.blobUpload) { const uploadBlobCallback = typeof options.blobUpload === 'function' ? options.blobUpload : uploadBlob; json = await uploadBlobCallback(url, cookie, sessionID, input, options); } else { const mergedData: RequestData = { input }; if (getConfig().setCookieOnRequest) { // We make sure that if setCookieOnRequest is true, we never set cookie to // undefined. null has a special meaning here: we don't currently have a // cookie, and we want the server to specify the new cookie it will generate // in the response body rather than the response header. See // session-types.js for more details on why we specify cookies in the body. mergedData.cookie = cookie ? cookie : null; } if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user is // not logged in on web. mergedData.sessionID = sessionID ? sessionID : null; } const fetchPromise = (async (): Promise => { const response = await fetch(url, { method: 'POST', // This is necessary to allow cookie headers to get passed down to us credentials: 'same-origin', body: JSON.stringify(mergedData), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); const text = await response.text(); try { - return _cloneDeep(JSON.parse(text)); + return JSON.parse(text); } catch (e) { console.log(text); throw e; } })(); const timeout = options && options.timeout ? options.timeout : fetchJSONTimeout; if (!timeout) { json = await fetchPromise; } else { const rejectPromise = (async () => { await sleep(timeout); throw new FetchTimeout( `fetchJSON timed out call to ${endpoint}`, endpoint, ); })(); json = await Promise.race([fetchPromise, rejectPromise]); } } const { cookieChange, error, payload, currentUserInfo } = json; const sessionChange: ?ServerSessionChange = cookieChange; if (sessionChange) { const { threadInfos, userInfos, ...rest } = sessionChange; const clientSessionChange = rest.cookieInvalidated ? rest : { cookieInvalidated: false, currentUserInfo, ...rest }; if (clientSessionChange.cookieInvalidated) { const maybeReplacement = await cookieInvalidationRecovery( clientSessionChange, ); if (maybeReplacement) { return await maybeReplacement(endpoint, input, options); } } setNewSession(clientSessionChange, error); } if (error) { throw new ServerError(error, payload); } options?.getResultInfo?.({ interface: 'REST' }); return json; } export default fetchJSON; diff --git a/lib/utils/upload-blob.js b/lib/utils/upload-blob.js index 0cafab469..62e1cf63b 100644 --- a/lib/utils/upload-blob.js +++ b/lib/utils/upload-blob.js @@ -1,119 +1,118 @@ // @flow import invariant from 'invariant'; -import _cloneDeep from 'lodash/fp/cloneDeep'; import _throttle from 'lodash/throttle'; import { getConfig } from './config'; import type { FetchJSONOptions, FetchJSONServerResponse } from './fetch-json'; function uploadBlob( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ): Promise { const formData = new FormData(); if (getConfig().setCookieOnRequest) { // We make sure that if setCookieOnRequest is true, we never set cookie to // undefined. null has a special meaning here: we don't currently have a // cookie, and we want the server to specify the new cookie it will generate // in the response body rather than the response header. See // session-types.js for more details on why we specify cookies in the body. formData.append('cookie', cookie ? cookie : ''); } if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user is // not logged in on web. formData.append('sessionID', sessionID ? sessionID : ''); } for (const key in input) { if (key === 'multimedia' || key === 'cookie' || key === 'sessionID') { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); formData.append(key, value); } const { multimedia } = input; if (multimedia && Array.isArray(multimedia)) { for (const media of multimedia) { // We perform an any-cast here because of React Native. Though Blob // support was introduced in react-native@0.54, it isn't compatible with // FormData. Instead, React Native requires a specific object format. formData.append('multimedia', (media: any)); } } const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.withCredentials = true; xhr.setRequestHeader('Accept', 'application/json'); if (options && options.timeout) { xhr.timeout = options.timeout; } if (options && options.onProgress) { const { onProgress } = options; xhr.upload.onprogress = _throttle( ({ loaded, total }) => onProgress(loaded / total), 50, ); } let failed = false; const responsePromise = new Promise((resolve, reject) => { xhr.onload = () => { if (failed) { return; } const text = xhr.responseText; try { - resolve(_cloneDeep(JSON.parse(text))); + resolve(JSON.parse(text)); } catch (e) { console.log(text); reject(e); } }; xhr.onabort = () => { failed = true; reject(new Error('request aborted')); }; xhr.onerror = (event) => { failed = true; reject(event); }; if (options && options.timeout) { xhr.ontimeout = (event) => { failed = true; reject(event); }; } if (options && options.abortHandler) { options.abortHandler(() => { failed = true; reject(new Error('request aborted')); xhr.abort(); }); } }); if (!failed) { xhr.send(formData); } return responsePromise; } export type UploadBlob = typeof uploadBlob; export { uploadBlob };