mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5c4811622 | ||
|
|
cadbf7a63c | ||
|
|
b16757f06f |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user