Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(eas-cli): swap node-fetch for undici to support Node 22 better #2414

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

byCedric
Copy link
Member

@byCedric byCedric commented Jun 7, 2024

⚠️ Warning, there seems to be an issue related to FormData and fetch. Right now, we are using import 'form-data', which doesn't get handled properly with undici or Node fetch. #2414 (comment)

Why

Fixes ENG-12411

It also brings us closer to Node's own fetch implementation, based on undici and is fetch-spec compliant. This is to improve Node 22 support, without warnings of deprecated system packages.

How

Test Plan

See tests in GitHub Actions

Copy link

linear bot commented Jun 7, 2024

@byCedric
Copy link
Member Author

byCedric commented Jun 7, 2024

/changelog-entry bug-fix Swapped node-fetch for undici to support Node 22

Copy link

github-actions bot commented Jun 7, 2024

Size Change: +288 kB (+0.55%)

Total Size: 52.2 MB

Filename Size Change
./packages/eas-cli/dist/eas-linux-x64.tar.gz 52.2 MB +288 kB (+0.55%)

compressed-size-action

Copy link

codecov bot commented Jun 7, 2024

Codecov Report

Attention: Patch coverage is 86.95652% with 6 lines in your changes missing coverage. Please review.

Project coverage is 53.58%. Comparing base (eb4c5e0) to head (e5c4811).

Files Patch % Lines
...dUtils/context/contextUtils/createGraphqlClient.ts 25.00% 3 Missing ⚠️
packages/eas-cli/src/utils/download.ts 93.34% 2 Missing ⚠️
packages/eas-cli/src/fetch.ts 88.89% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2414      +/-   ##
==========================================
+ Coverage   53.45%   53.58%   +0.14%     
==========================================
  Files         530      530              
  Lines       19509    19513       +4     
  Branches     3968     3968              
==========================================
+ Hits        10427    10455      +28     
+ Misses       8330     8311      -19     
+ Partials      752      747       -5     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

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) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this fix the type error?

Suggested change
fetch: (url: RequestInfo, init?: RequestInit) =>
fetch: (url: RequestInfo | URL, init?: RequestInit) =>

Copy link
Member Author

@byCedric byCedric Jun 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately not, it seems that typescript interprets the @types/node differently.

TL;DR;

  • in Node, they use url: string | URL | globalThis.Request (see here)
  • in Undici, they use url: RequestInfo, where type RequestInfo = string | URL | Request (see here)

These should not conflict, but I have a feeling that it may be caused by this class extension in @types/node

Copy link
Member Author

@byCedric byCedric Jun 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we might be dropping support for Node 16 (see PR #2413), we could just avoid importing fetch from undici though. That should resolve this typing issue, and remove the need for the nock <> undici mock/workaround as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening a stacked PR for this, we need to refactor more than just removing the import { fetch } from 'undici'. This stacked PR may or may not succeed, depending on how much work still is required.

#2419

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so there is an issue. The summary is:

  • FormData is used from the form-data package, which is old and uses util.isArray (last update was 3y ago)
  • form-data is different from undici's FormData / Node built-in FormData
  • form-data supports Node's fs.createReadStream instances (ReadStream types)
  • undici does NOT support Node's ReadStream type, and only works with File (added in 18.13.0) or Blob (added in 14.x.x)
  • Starting from Node 20.x.x, there is a fs.openAsBlob method to stream files as blobs, and pass it to FormData, but we support Node 18 still

There are rather "dirty" hacks to work around this limitation, e.g. this one. I also have concerns with undici's FormData not sending the Content-Length header.

Right now, undici also doesn't play nice with the form-data package. We'd need to add a Readable.toWeb(form) to make it work.

For now, I've reached my timebox. Happy to circle back to this later though.

Copy link
Member Author

@byCedric byCedric Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is an example request made, when using the workaround from SO. It's still missing the Content-Length header when streaming. When using a blob (in-memory read), it adds the header.

Using built-in fetch, built-in FormData, and file stream image
Using built-in fetch, built-in FormData, and Blob (loading in-memory) image
Using built-in fetch, built-in FormData, and fs.openAsBlob (streaming, but Node 20+) image

While, originally, this is the request made:

Using node-fetch, form-data, and file stream image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node 18's end of life will be in April 2025. So it's not soon, but eventually we'll be able to rely on fs.openAsBlob. Could you document these findings in a task assigned to yourself so that it's easier to follow up on this after we've dropped Node 18?

Copy link
Member Author

@byCedric byCedric Jun 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One workaround for Node 18 that I've found to work well is this one:

// Create a FormData object, and populate data
const form = new FormData();
const file = await createBlobFromPath(testFile);
form.append('some-value', 'value');
form.append('file', file, path.basename(testFile));


// Use `fs.openAsBlob` if available, else polyfill on Node 18
async function createBlobFromPath(filePath) {
  if (typeof fs.openAsBlob === 'function') {
    console.log('Using fs.openAsBlob:', filePath);
    return await fs.openAsBlob(filePath);
  }

  console.log('Falling back to File polyfill:', filePath);
  const { size } = await fs.promises.stat(filePath);

  return {
    [Symbol.toStringTag]: 'File', // Trick Node into thinking this is a File reference
    get type() { return undefined }, // Always undefined, it's the mime type
    get size() { return size }, // Ensure the `content-length` header is set through form data
    stream() { return Readable.toWeb(fs.createReadStream(filePath)) }, // The magic part that makes it work

    // Not necessary as we override the filename through formdata
    // get name() { return name },

    // Could add the full API, but it's not necessary for form data
    // arrayBuffer(...args) { return this.stream().arrayBuffer(...args) },
    // text(...args) { return this.stream().text(...args) },
    // slice(...args) { return this.stream().slice(...args) }
  };
}

This seems to run fine on Node 18 for ubuntu, macos, and windows. It's still leveraging Node 20+ APIs, but with a weird fallback. It's not spec-compliant, but enough compliant for FormData itself, and includes the content-length header we need.

packages/eas-cli/src/fetch.ts Outdated Show resolved Hide resolved
@byCedric byCedric force-pushed the @bycedric/eas-cli/swap-node-fetch-for-undici branch from 598f48d to e5c4811 Compare June 8, 2024 13:38
Copy link

github-actions bot commented Jun 8, 2024

✅ Thank you for adding the changelog entry!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants