diff --git a/CHANGELOG.md b/CHANGELOG.md index f678a68a0e..d104d3a73f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes - Correctly parse the EXPO_APPLE_PROVIER_ID environment variable. ([#2349](https://github.com/expo/eas-cli/pull/2349) by [@louix](https://github.com/louix)) +- Swapped `node-fetch` for `undici` to support Node 22. ([#2414](https://github.com/expo/eas-cli/pull/2414) by [@byCedric](https://github.com/byCedric)) ### ๐Ÿงน Chores diff --git a/packages/eas-cli/__mocks__/undici.ts b/packages/eas-cli/__mocks__/undici.ts new file mode 100644 index 0000000000..845866e561 --- /dev/null +++ b/packages/eas-cli/__mocks__/undici.ts @@ -0,0 +1,7 @@ +const undici = require('undici'); + +// This workaround swaps `undici.fetch` for `global.fetch` to connect Nock with Undici. +// See: https://github.com/nock/nock/issues/2183 +require('nock'); + +module.exports = { ...undici, fetch: global.fetch }; diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 59a591ce1f..c48ea3fd2d 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -64,7 +64,6 @@ "mime": "3.0.0", "minimatch": "5.1.2", "nanoid": "3.3.4", - "node-fetch": "2.6.7", "node-forge": "1.3.1", "nullthrows": "1.1.1", "ora": "5.1.0", @@ -81,6 +80,7 @@ "terminal-link": "2.1.1", "tslib": "2.6.2", "turndown": "7.1.2", + "undici": "6.18.2", "untildify": "4.0.0", "uuid": "9.0.1", "wrap-ansi": "7.0.0" @@ -98,7 +98,6 @@ "@types/getenv": "^1.0.0", "@types/jsonwebtoken": "8.5.1", "@types/mime": "3.0.1", - "@types/node-fetch": "2.6.2", "@types/node-forge": "1.3.1", "@types/pngjs": "6.0.4", "@types/promise-retry": "1.1.3", @@ -113,7 +112,7 @@ "express": "4.19.2", "memfs": "3.4.13", "mockdate": "3.0.5", - "nock": "13.4.0", + "nock": "14.0.0-beta.7", "rimraf": "3.0.2", "ts-deepmerge": "6.2.0", "ts-mockito": "2.6.1", diff --git a/packages/eas-cli/src/commandUtils/context/contextUtils/createGraphqlClient.ts b/packages/eas-cli/src/commandUtils/context/contextUtils/createGraphqlClient.ts index 70ece9cdd3..8f9d6ae305 100644 --- a/packages/eas-cli/src/commandUtils/context/contextUtils/createGraphqlClient.ts +++ b/packages/eas-cli/src/commandUtils/context/contextUtils/createGraphqlClient.ts @@ -11,10 +11,9 @@ import { } from '@urql/core'; import { retryExchange } from '@urql/exchange-retry'; import { DocumentNode } from 'graphql'; -import fetch from 'node-fetch'; import { getExpoApiBaseUrl } from '../../../api'; -import { httpsProxyAgent } from '../../../fetch'; +import fetch, { RequestError, RequestInfo, RequestInit } from '../../../fetch'; export interface ExpoGraphqlClient extends Client { query( @@ -44,19 +43,17 @@ export function createGraphqlClient(authInfo: { }), fetchExchange, ], - // @ts-expect-error Type 'typeof fetch' is not assignable to type '(input: RequestInfo, init?: RequestInit | undefined) => Promise'. - fetch, - fetchOptions: (): RequestInit => { + // @ts-expect-error - Type '(url: RequestInfo, init?: RequestInit) => Promise' is not assignable to type '{ (input: RequestInfo | URL, init?: RequestInit | undefined): Promise; (input: string | Request | URL, init?: RequestInit | undefined): Promise<...>; }'. + fetch: (url: RequestInfo, init?: RequestInit) => + fetch(url, init).catch((error: RequestError) => error.response), + fetchOptions: () => { const headers: Record = {}; if (authInfo.accessToken) { headers.authorization = `Bearer ${authInfo.accessToken}`; } else if (authInfo.sessionSecret) { headers['expo-session'] = authInfo.sessionSecret; } - return { - ...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}), - headers, - }; + return { headers }; }, }) as ExpoGraphqlClient; } diff --git a/packages/eas-cli/src/fetch.ts b/packages/eas-cli/src/fetch.ts index 4a3f4f408f..648029f4e7 100644 --- a/packages/eas-cli/src/fetch.ts +++ b/packages/eas-cli/src/fetch.ts @@ -1,8 +1,15 @@ -import { Agent } from 'https'; -import createHttpsProxyAgent from 'https-proxy-agent'; -import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch'; +import { env } from 'node:process'; +import { + ProxyAgent, + type RequestInfo, + type RequestInit, + type Response, + fetch, + getGlobalDispatcher, + setGlobalDispatcher, +} from 'undici'; -export * from 'node-fetch'; +export { Agent, Headers, type RequestInfo, type RequestInit, Response } from 'undici'; export class RequestError extends Error { constructor( @@ -13,23 +20,21 @@ export class RequestError extends Error { } } -function createHttpsAgent(): Agent | null { - const httpsProxyUrl = process.env.https_proxy; - if (!httpsProxyUrl) { - return null; - } - return createHttpsProxyAgent(httpsProxyUrl); -} - -export const httpsProxyAgent: Agent | null = createHttpsAgent(); - export default async function (url: RequestInfo, init?: RequestInit): Promise { - const response = await fetch(url, { - ...init, - ...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}), - }); + installProxyAgent(); + + const response = await fetch(url, init); if (response.status >= 400) { throw new RequestError(`Request failed: ${response.status} (${response.statusText})`, response); } + return response; } + +function installProxyAgent(): void { + const httpsProxyUrl = env.https_proxy; + + if (httpsProxyUrl && !(getGlobalDispatcher() instanceof ProxyAgent)) { + setGlobalDispatcher(new ProxyAgent(httpsProxyUrl)); + } +} diff --git a/packages/eas-cli/src/uploads.ts b/packages/eas-cli/src/uploads.ts index 051263424c..ec05263e29 100644 --- a/packages/eas-cli/src/uploads.ts +++ b/packages/eas-cli/src/uploads.ts @@ -1,10 +1,9 @@ import FormData from 'form-data'; import fs from 'fs-extra'; -import { Response } from 'node-fetch'; import promiseRetry from 'promise-retry'; import { ExpoGraphqlClient } from './commandUtils/context/contextUtils/createGraphqlClient'; -import fetch from './fetch'; +import fetch, { type Response } from './fetch'; import { UploadSessionType } from './graphql/generated'; import { SignedUrl, UploadSessionMutation } from './graphql/mutations/UploadSessionMutation'; import { ProgressHandler } from './utils/progress'; diff --git a/packages/eas-cli/src/utils/__tests__/download-test.ts b/packages/eas-cli/src/utils/__tests__/download-test.ts new file mode 100644 index 0000000000..9bf79520de --- /dev/null +++ b/packages/eas-cli/src/utils/__tests__/download-test.ts @@ -0,0 +1,104 @@ +import nock from 'nock'; + +import { getExpoApiBaseUrl } from '../../api'; +import { RequestError } from '../../fetch'; +import { wrapFetchWithProgress } from '../download'; + +describe(wrapFetchWithProgress, () => { + const url = getExpoApiBaseUrl(); + + it('returns response with body', async () => { + nock(url) + .get('/test-body') + .reply(200, 'success', { 'Content-Length': String(Buffer.byteLength('success')) }); + + const response = await wrapFetchWithProgress()(`${url}/test-body`, {}, jest.fn()); + + expect(await response.text()).toBe('success'); + }); + + it('calls progress handler when loading body', async () => { + const testSize = 1024 * 1024; // 1MB + + nock(url) + .get('/test-progress') + .reply(200, Buffer.alloc(testSize), { 'Content-Length': String(testSize) }); + + const progressTracker = jest.fn(); + const fetchWithProgress = wrapFetchWithProgress(); + const response = await fetchWithProgress(`${url}/test-progress`, {}, progressTracker); + + // Response should be successful + expect(response).toMatchObject({ ok: true }); + // Load the the response body to trigger the progress events + expect(await response.blob()).not.toBeUndefined(); + // Progress tracker should start at 0% + expect(progressTracker).toHaveBeenCalledWith({ + isComplete: false, + progress: { + total: testSize, + percent: 0, + transferred: 0, + }, + }); + // Progress tracker should end at 100% + expect(progressTracker).toHaveBeenCalledWith({ + isComplete: true, + progress: { + total: testSize, + percent: 1, + transferred: testSize, + }, + }); + }); + + it('skips progress events when request fails', async () => { + nock(url) + .get('/test-fail') + .reply(404, 'Not Found', { 'Content-Length': String(Buffer.byteLength('Not Found')) }); + + const progressTracker = jest.fn(); + const response = await wrapFetchWithProgress()(`${url}/test-fail`, {}, progressTracker).catch( + (requestError: RequestError) => requestError.response + ); + + // Response should not be successful + expect(response).toMatchObject({ ok: false }); + // Repsonse should contain our error message + expect(await response.text()).toBe('Not Found'); + // No progression events should be called + expect(progressTracker).not.toHaveBeenCalled(); + }); + + it('skips progress events when response is empty', async () => { + nock(url).get('/test-empty').reply(204, undefined, { 'Content-Length': '0' }); + + const progressTracker = jest.fn(); + const response = await wrapFetchWithProgress()(`${url}/test-empty`, {}, progressTracker); + + // Response should be successful + expect(response).toMatchObject({ ok: true }); + // Body should be empty + expect(await response.text()).toBe(''); + // No progression events should be called + expect(progressTracker).not.toHaveBeenCalled(); + }); + + it('skips progress events when no content-length header is available', async () => { + nock(url).get('/test-missing-content-length').reply(200, 'success'); + + const progressTracker = jest.fn(); + const response = await wrapFetchWithProgress()( + `${url}/test-missing-content-length`, + {}, + progressTracker + ); + + // Response should be successful + expect(response).toMatchObject({ ok: true }); + // Body should be empty + expect(await response.text()).toBe('success'); + // No progression events should be called + expect(progressTracker).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/utils/download.ts b/packages/eas-cli/src/utils/download.ts index f9e08e8127..ffa9383de6 100644 --- a/packages/eas-cli/src/utils/download.ts +++ b/packages/eas-cli/src/utils/download.ts @@ -2,7 +2,7 @@ import spawnAsync from '@expo/spawn-async'; import glob from 'fast-glob'; import fs from 'fs-extra'; import path from 'path'; -import { Stream } from 'stream'; +import { Readable, Stream } from 'stream'; import { extract } from 'tar'; import { promisify } from 'util'; import { v4 as uuidv4 } from 'uuid'; @@ -10,14 +10,15 @@ import { v4 as uuidv4 } from 'uuid'; import { formatBytes } from './files'; import { getTmpDirectory } from './paths'; import { ProgressHandler, createProgressTracker } from './progress'; -import fetch, { RequestInit, Response } from '../fetch'; +import fetch, { Agent, RequestInit, Response } from '../fetch'; import { AppPlatform } from '../graphql/generated'; import Log from '../log'; import { promptAsync } from '../prompts'; const pipeline = promisify(Stream.pipeline); -function wrapFetchWithProgress(): ( +/** @internal - Exposed for testing only */ +export function wrapFetchWithProgress(): ( url: string, init: RequestInit, progressHandler: ProgressHandler @@ -26,47 +27,68 @@ function wrapFetchWithProgress(): ( return async (url: string, init: RequestInit, progressHandler: ProgressHandler) => { const response = await fetch(url, init); - if (response.ok) { - const totalDownloadSize = response.headers.get('Content-Length'); - const total = Number(totalDownloadSize); + // Abort if the request failed, or if no response body is available + if (!response.ok || !response.body) { + return response; + } - if (!totalDownloadSize || isNaN(total) || total < 0) { - Log.warn( - 'Progress callback not supported for network request because "Content-Length" header missing or invalid in response from URL:', - url.toString() - ); - return response; - } + // Calculate total progress size + const contentLength = response.headers.get('Content-Length'); + const progressTotal = contentLength ? parseInt(contentLength, 10) : undefined; - let length = 0; - const onProgress = (chunkLength?: number): void => { - if (chunkLength) { - length += chunkLength; - } + if (!progressTotal || isNaN(progressTotal) || progressTotal < 0) { + // TODO: add debug logging explaining it bailed out due to faulty server response + return response; + } - const progress = length / total; + // Prepare progress coutner and handler + let progressLength = 0; + const onProgress = (chunkSize = 0): void => { + progressLength += chunkSize; - if (!didProgressBarFinish) { - progressHandler({ - progress: { total, percent: progress, transferred: length }, - isComplete: total === length, - }); - if (total === length) { - didProgressBarFinish = true; - } + if (!didProgressBarFinish) { + if (progressLength === progressTotal) { + didProgressBarFinish = true; } - }; - response.body.on('data', chunk => { - onProgress(chunk.length); - }); + progressHandler({ + isComplete: progressLength === progressTotal, + progress: { + total: progressTotal, + percent: progressLength / progressTotal, + transferred: progressLength, + }, + }); + } + }; - response.body.on('end', () => { + // Create a new body-wrapping stream that monitors progression + const bodyReader = response.body.getReader(); + const bodyWithProgress = new ReadableStream({ + start(controller) { + // Notify the stream is starting, and read the first chunk onProgress(); - }); - } + next(); + + function next(): void { + bodyReader.read().then(({ done, value }) => { + // Close the controller when the stream is done + if (done) { + return controller.close(); + } + + // Update progression + onProgress(Buffer.byteLength(value)); + // Continue the stream and read the next chunk + controller.enqueue(value); + next(); + }); + } + }, + }); - return response; + // @ts-expect-error - ReadableStream is valid as Response body, but TypeScript disagree + return new Response(bodyWithProgress, response); }; } @@ -82,7 +104,8 @@ async function downloadFileWithProgressTrackerAsync( const response = await wrapFetchWithProgress()( url, { - timeout: 1000 * 60 * 5, // 5 minutes + // Timeout: 5 minutes + dispatcher: new Agent({ connectTimeout: 1000 * 60 * 5 }), }, createProgressTracker({ message: progressTrackerMessage, @@ -90,11 +113,11 @@ async function downloadFileWithProgressTrackerAsync( }) ); - if (!response.ok) { + if (!response.ok || !response.body) { throw new Error(`Failed to download file from ${url}`); } - await pipeline(response.body, fs.createWriteStream(outputPath)); + await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(outputPath)); } catch (error: any) { if (await fs.pathExists(outputPath)) { await fs.remove(outputPath); diff --git a/packages/eas-cli/src/utils/image.ts b/packages/eas-cli/src/utils/image.ts index 8fd85c3717..bf53428c20 100644 --- a/packages/eas-cli/src/utils/image.ts +++ b/packages/eas-cli/src/utils/image.ts @@ -1,5 +1,6 @@ import fs from 'fs-extra'; import { Metadata, PNG } from 'pngjs'; +import { Readable } from 'stream'; import fetch from '../fetch'; @@ -72,7 +73,7 @@ export async function isPNGAsync(imagePathOrURL: string): Promise { async function getImageStreamAsync(imagePathOrURL: string): Promise { if (isURL(imagePathOrURL)) { const response = await fetch(imagePathOrURL); - return response.body; + return Readable.fromWeb(response.body!); } else { return fs.createReadStream(imagePathOrURL); } diff --git a/yarn.lock b/yarn.lock index 171b675bf3..0386e6eda1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3838,14 +3838,6 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= -"@types/node-fetch@2.6.2": - version "2.6.2" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da" - integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A== - dependencies: - "@types/node" "*" - form-data "^3.0.0" - "@types/node-forge@1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.1.tgz#49e44432c306970b4e900c3b214157c480af19fa" @@ -7113,15 +7105,6 @@ form-data@4.0.0, form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -form-data@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" - integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -10465,12 +10448,11 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" -nock@13.4.0: - version "13.4.0" - resolved "https://registry.yarnpkg.com/nock/-/nock-13.4.0.tgz#60aa3f7a4afa9c12052e74d8fb7550f682ef0115" - integrity sha512-W8NVHjO/LCTNA64yxAPHV/K47LpGYcVzgKd3Q0n6owhwvD0Dgoterc25R4rnZbckJEb6Loxz1f5QMuJpJnbSyQ== +nock@14.0.0-beta.7: + version "14.0.0-beta.7" + resolved "https://registry.yarnpkg.com/nock/-/nock-14.0.0-beta.7.tgz#bdb3f3cfa2276659c87412f6dff2aea9ee69a7fc" + integrity sha512-+EQMm5W9K8YnBE2Ceg4hnJynaCZmvK8ZlFXQ2fxGwtkOkBUq8GpQLTks2m1jpvse9XDxMDDOHgOWpiznFuh0bA== dependencies: - debug "^4.1.0" json-stringify-safe "^5.0.1" propagate "^2.0.0" @@ -13537,6 +13519,11 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici@6.18.2: + version "6.18.2" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.18.2.tgz#f662a5dc33cf654fc412a9912e5a07b138d75c97" + integrity sha512-o/MQLTwRm9IVhOqhZ0NQ9oXax1ygPjw6Vs+Vq/4QRjbOAC3B1GCHy7TYxxbExKlb7bzDRzt9vBWU6BDz0RFfYg== + unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"