Compare commits

...
10 changed files with 223 additions and 100 deletions

No files matched your search

+1
View File
@@ -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
+7
View File
@@ -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 };
+2 -3
View File
@@ -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",
@@ -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<Data = any, Variables extends AnyVariables = AnyVariables>(
@@ -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<Response>'.
fetch,
fetchOptions: (): RequestInit => {
// @ts-expect-error - Type '(url: RequestInfo, init?: RequestInit) => Promise<Response>' is not assignable to type '{ (input: RequestInfo | URL, init?: RequestInit | undefined): Promise<Response>; (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<string, string> = {};
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;
}
+23 -18
View File
@@ -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<Response> {
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));
}
}
+1 -2
View File
@@ -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';
@@ -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();
});
});
+68 -45
View File
@@ -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);
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;
}
let length = 0;
const onProgress = (chunkLength?: number): void => {
if (chunkLength) {
length += chunkLength;
}
const progress = length / total;
if (!didProgressBarFinish) {
progressHandler({
progress: { total, percent: progress, transferred: length },
isComplete: total === length,
});
if (total === length) {
didProgressBarFinish = true;
}
}
};
response.body.on('data', chunk => {
onProgress(chunk.length);
});
response.body.on('end', () => {
onProgress();
});
// Abort if the request failed, or if no response body is available
if (!response.ok || !response.body) {
return response;
}
return response;
// Calculate total progress size
const contentLength = response.headers.get('Content-Length');
const progressTotal = contentLength ? parseInt(contentLength, 10) : undefined;
if (!progressTotal || isNaN(progressTotal) || progressTotal < 0) {
// TODO: add debug logging explaining it bailed out due to faulty server response
return response;
}
// Prepare progress coutner and handler
let progressLength = 0;
const onProgress = (chunkSize = 0): void => {
progressLength += chunkSize;
if (!didProgressBarFinish) {
if (progressLength === progressTotal) {
didProgressBarFinish = true;
}
progressHandler({
isComplete: progressLength === progressTotal,
progress: {
total: progressTotal,
percent: progressLength / progressTotal,
transferred: progressLength,
},
});
}
};
// 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();
});
}
},
});
// @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);
+2 -1
View File
@@ -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<boolean> {
async function getImageStreamAsync(imagePathOrURL: string): Promise<NodeJS.ReadableStream> {
if (isURL(imagePathOrURL)) {
const response = await fetch(imagePathOrURL);
return response.body;
return Readable.fromWeb(response.body!);
} else {
return fs.createReadStream(imagePathOrURL);
}
Generated
+9 -22
View File
@@ -3838,14 +3838,6 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
"@types/[email protected]":
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/[email protected]":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.1.tgz#49e44432c306970b4e900c3b214157c480af19fa"
@@ -7113,15 +7105,6 @@ [email protected], 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"
[email protected]:
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==
[email protected].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==
[email protected]:
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"