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

Different strategy for installing platform-specific binaries #789

Closed
eduardoboucas opened this issue Feb 11, 2021 · 25 comments · Fixed by #1621
Closed

Different strategy for installing platform-specific binaries #789

eduardoboucas opened this issue Feb 11, 2021 · 25 comments · Fixed by #1621

Comments

@eduardoboucas
Copy link

eduardoboucas commented Feb 11, 2021

Hi! 👋🏻

We're looking to integrate esbuild with the Netlify CLI and some users are reporting permission-related errors when installing the application, which trace back to #369.

I understand that this is a result of the UID/GID switching that npm does when running scripts, which is problematic when the package is installed as root.

Rather than manually fetching the appropriate binary as part of the postinstall script, could the esbuild package list the various platform-specific packages as optionalDependencies, and shift to npm itself the responsibility of figuring out which packages to installed based on the cpu and os properties? We're doing this for one of our packages that also includes a Go binary and it works well.

I'm happy to contribute with a pull request, but I wanted to check whether this is something you'd be interested in, or even if it's something you've already considered and discarded for a particular reason.

Thanks in advance!

@eduardoboucas eduardoboucas changed the title Solution for permission errors on installation Different strategy for installing platform-specific binaries Feb 11, 2021
@evanw
Copy link
Owner

evanw commented Feb 11, 2021

could the esbuild package list the various platform-specific packages as optionalDependencies

I have considered implementing the install script this way but I didn't go down that route because if I recall correctly it makes the installation output really noisy with all of the errors about unmet optional dependencies, which is a poor user experience. I'm also not totally sure what the guarantees around optional dependencies are (since I need them to be required, not optional) and if there are any situations where that could backfire and leave you with a broken package. Especially with all of the various alternate package managers other than npm that people like to use. Having my own install script gives me more control over these things.

I'd really rather not change the whole approach of the install script at this point because it's undergone a lot of evolution and has accumulated a lot of special cases that solve real problems. It's relatively stable at this point and changing it would risk breaking people, and would likely be a ton of work for me since I'd expect dozens of support requests from the fallout. It's at least not something I should do without a breaking change in the version number.

Depending on what you're doing, one possibility could be for you to just make your own package with all of esbuild's platform-specific packages as optional dependencies as you suggested. That should let you do this without any changes to esbuild itself. Doing this would at least be a less disruptive way of trying out the approach than involving esbuild's whole user base in the experiment.

@eduardoboucas
Copy link
Author

Thanks for the additional context, @evanw. I completely understand your position.

If we do decide to explore this experiment, we'll make sure to report back with our findings, so that you can decide whether this is something you want to revisit in the future.

Thanks for your work. 🙌🏻

@eduardoboucas
Copy link
Author

Depending on what you're doing, one possibility could be for you to just make your own package with all of esbuild's platform-specific packages as optional dependencies as you suggested

After looking into this for a bit, I think the main challenge would be to ensure that our package would benefit from any updates made upstream. We love esbuild and are excited to get the upcoming updates, so we were hoping to avoid anything that feels like a fork. The esbuild package includes not only the script for fetching the binaries, but also the entire JavaScript API, which would have to live on our side.

Would you be open to having a separate npm module that published from this repository, alongside the others? It would essentially be a copy of https://github.com/evanw/esbuild/blob/master/npm/esbuild/package.json, but with the optionalDependencies block and without the postinstall script.

If done from this repo, we would ensure that this module would include the latest changes, and it should be just a matter of adding an additional item to the Makefile.

Thanks!

@eduardoboucas eduardoboucas reopened this Feb 11, 2021
@evanw
Copy link
Owner

evanw commented Feb 13, 2021

I think it's worth getting an experimental package up with an example of this technique to try out. That should make it possible to test it with various package managers, operating systems, and different setting configurations and see what breaks.

@osdevisnot
Copy link
Contributor

osdevisnot commented Feb 20, 2021

I wanted to record my own experience with this approach.

I've authored sorvor with platform specific packages as optionalDependencies.

This setup is mostly working on Mac and Linux, except few instances:

  • NPM V7 - skips optional dependency checks and install all binaries for all platforms - issue link
  • NPM V6 - does not create bin links in node_modules/.bin only when installing with --save-dev (works without --save-dev)

I still did not test this with yarn berry or windows (I don't have windows machine unfortunately).

PS: yarn works correctly with various combinations of yarn versions and install flags. This was rather surprising as I expected npm to be more stable.

@evanw
Copy link
Owner

evanw commented Feb 20, 2021

Thanks very much for the report. It's good to have this additional data.

NPM V7 - skips optional dependency checks and install all binaries for all platforms

This seems like a pretty significant shortcoming. It seems like this means the approach just won't work after all. Is that correct?

@eduardoboucas
Copy link
Author

This seems like a pretty significant shortcoming. It seems like this means the approach just won't work after all. Is that correct?

It is indeed an issue with npm 7. I have filed an issue and there's already a pull request for it, so it should be fixed with the next release of the npm CLI.

Regardless, as per the release notes of npm 7, «scripts are always run with the effective uid and gid of the working directory owner», which means that the permissions errors I reported should disappear. We've had users confirming that the errors are gone when installing with npm 7.

@osdevisnot
Copy link
Contributor

It seems like this means the approach just won't work after all. Is that correct?

This is too early to call. As @eduardoboucas mentioned, the linked issue with NPM V7 is marked as closed and the fix will be available in next release.

NPM V7 makes this even more better, because user's do not see optional dependency warnings as observed with NPM V6 client as confirmed here

@cideM
Copy link

cideM commented May 7, 2021

I came here from netlify-cli which is currently broken in Nixpkgs, because of the transitive dependency on esbuild, which is trying to download from NPM from within a sandbox environment (the Nix builder). I know us Nix users are a minority, but running imperative commands in JS files from post install scripts makes it exceptionally hard to package applications like this in a declarative way. After all, the dependency on the platform specific binaries isn't actually declared anywhere.

@evanw
Copy link
Owner

evanw commented Jul 12, 2021

Note to self from #1434: Another thing to investigate here is to see how various JS package managers handle optional dependencies when there are multiple versions of esbuild installed simultaneously. It would be very bad for two different esbuild packages to be installed but for the optional dependencies to either only be installed once or not be installed at all.

@evanw
Copy link
Owner

evanw commented Aug 1, 2021

I just published a package called esbuild-experimental-optdeps to experiment with the optionalDependencies installation strategy. There are two versions (esbuild-experimental-optdeps@0.12.2 and esbuild-experimental-optdeps@0.12.3) to allow for testing complex dependency scenarios where esbuild is included multiple times at different versions.

I have installed these packages with npm v7, pnpm, and yarn and they all appear to work (at least on macOS, there may be problems on Windows as usual). I also had to add hacks for Yarn's PnP mode which has a virtual file system that messes with stuff but it also appears to work under Yarn PnP after the hacks. These new packages even work with --ignore-scripts since they don't use a postinstall script.

The major drawback appears to be that everything in optionalDependencies is downloaded and then all but one of those packages is discarded. So cached reinstallations are still fast but the initial installation is slower. I could try to mitigate this by publishing a lightweight shim package in front of every heavyweight binary executable package assuming that package managers wouldn't install dependencies of unavailable optional dependencies (I haven't verified this yet). Another minor drawback is that this installation strategy causes Yarn's installer to print out a lot of useless stuff (each failed optional dependency generates three log lines).

What do people on this thread think of this experiment? Are people with installation troubles (alternative registries, complex proxies, offline installations, etc.) able to install these experimental packages?

@IgorMinar
Copy link

@bazel/bazel has been using the optionalDependencies workaround for some time while it works reliably, it suffers from the downsides you already mentioned (wasted bandwidth, noise in the CLI output): https://unpkg.com/browse/@bazel/bazel@2.1.0/package.json

We haven't tried option to guard the download with a lightweight shim package, which could solve the bandwidth problem but not the noise problem.

Ideal solution would be for the package managers to not attempt to download optionalDependencies if they are platform specific (as declared with the os field in package.json: https://docs.npmjs.com/cli/v7/configuring-npm/package-json#os).

@evanw
Copy link
Owner

evanw commented Aug 7, 2021

Interesting. Thanks for the pointers.

I tried making a package that uses shims (esbuild-experimental-optdeps@0.12.4) and it looks like npm, pnpm, and yarn all still download everything even though they are obviously not needed.

@evanw
Copy link
Owner

evanw commented Aug 18, 2021

Latest update

After looking again it looks like npm@7.20.6 is actually only downloading the binary executable for the current OS. And after a very recent PR was merged, pnpm@6.14.0-0 also only downloads the binary executable for the current OS. However, both yarn@1.22.11 and yarn@3.0.1 (i.e. the latest Yarn version and the latest "berry" version) still download all packages for all OSes:

Click to expand the install log for npm@7.20.6 (✅ only 2 .tgz files are downloaded)
$ echo '{ "dependencies": { "esbuild-experimental-optdeps": "0.12.3" } }' > package.json
$ npm cache clean --force
$ npm install --ignore-scripts --verbose
npm verb cli [
npm verb cli   '/usr/local/bin/node',
npm verb cli   '/Users/evan/.npm-local/bin/npm',
npm verb cli   'install',
npm verb cli   '--ignore-scripts',
npm verb cli   '--verbose'
npm verb cli ]
npm info using npm@7.20.6
npm info using node@v16.5.0
npm timing npm:load:whichnode Completed in 1ms
npm timing config:load:defaults Completed in 1ms
npm timing config:load:file:/Users/evan/.npm-local/lib/node_modules/npm/npmrc Completed in 1ms
npm timing config:load:builtin Completed in 1ms
npm timing config:load:cli Completed in 2ms
npm timing config:load:env Completed in 0ms
npm timing config:load:file:/Users/evan/Desktop/.npmrc Completed in 1ms
npm timing config:load:project Completed in 1ms
npm timing config:load:file:/Users/evan/.npmrc Completed in 0ms
npm timing config:load:user Completed in 0ms
npm timing config:load:file:/Users/evan/.npm-local/etc/npmrc Completed in 1ms
npm timing config:load:global Completed in 1ms
npm timing config:load:validate Completed in 0ms
npm timing config:load:credentials Completed in 1ms
npm timing config:load:setEnvs Completed in 0ms
npm timing config:load Completed in 7ms
npm timing npm:load:configload Completed in 7ms
npm timing npm:load:setTitle Completed in 15ms
npm timing npm:load:setupLog Completed in 1ms
npm timing npm:load:cleanupLog Completed in 1ms
npm timing npm:load:configScope Completed in 0ms
npm timing npm:load:projectScope Completed in 0ms
npm timing npm:load Completed in 28ms
npm timing config:load:flatten Completed in 2ms
npm timing arborist:ctor Completed in 1ms
npm timing arborist:ctor Completed in 0ms
npm timing idealTree:init Completed in 11ms
npm timing idealTree:userRequests Completed in 0ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-experimental-optdeps 161ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-darwin-64 108ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-freebsd-64 238ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-freebsd-arm64 294ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-linux-mips64le 310ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-darwin-arm64 377ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-linux-32 386ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-linux-64 431ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-linux-arm 458ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-linux-ppc64le 479ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-windows-32 619ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-linux-arm64 630ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-windows-64 661ms
npm timing idealTree:#root Completed in 834ms
npm timing idealTree:node_modules/esbuild-experimental-optdeps Completed in 17ms
npm timing idealTree:node_modules/esbuild-darwin-64 Completed in 0ms
npm timing idealTree:node_modules/esbuild-darwin-arm64 Completed in 0ms
npm timing idealTree:node_modules/esbuild-freebsd-64 Completed in 0ms
npm timing idealTree:node_modules/esbuild-freebsd-arm64 Completed in 0ms
npm timing idealTree:node_modules/esbuild-linux-32 Completed in 0ms
npm timing idealTree:node_modules/esbuild-linux-64 Completed in 0ms
npm timing idealTree:node_modules/esbuild-linux-arm Completed in 0ms
npm timing idealTree:node_modules/esbuild-linux-arm64 Completed in 0ms
npm timing idealTree:node_modules/esbuild-linux-mips64le Completed in 0ms
npm timing idealTree:node_modules/esbuild-linux-ppc64le Completed in 0ms
npm timing idealTree:node_modules/esbuild-windows-32 Completed in 0ms
npm timing idealTree:node_modules/esbuild-windows-64 Completed in 0ms
npm timing idealTree:buildDeps Completed in 853ms
npm timing idealTree:fixDepFlags Completed in 1ms
npm timing idealTree Completed in 866ms
npm timing reify:loadTrees Completed in 867ms
npm timing reify:diffTrees Completed in 1ms
npm timing reify:retireShallow Completed in 0ms
npm timing reify:createSparse Completed in 5ms
npm timing reify:loadBundles Completed in 0ms
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-windows-64
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-windows-32
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-linux-ppc64le
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-linux-mips64le
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-linux-arm64
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-linux-arm
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-linux-64
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-linux-32
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-freebsd-arm64
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-freebsd-64
npm verb reify failed optional dependency /Users/evan/Desktop/node_modules/esbuild-darwin-arm64
npm timing reifyNode:node_modules/esbuild-windows-64 Completed in 4ms
npm timing reifyNode:node_modules/esbuild-windows-32 Completed in 4ms
npm timing reifyNode:node_modules/esbuild-linux-ppc64le Completed in 5ms
npm timing reifyNode:node_modules/esbuild-linux-mips64le Completed in 5ms
npm timing reifyNode:node_modules/esbuild-linux-arm64 Completed in 5ms
npm timing reifyNode:node_modules/esbuild-linux-arm Completed in 5ms
npm timing reifyNode:node_modules/esbuild-linux-64 Completed in 5ms
npm timing reifyNode:node_modules/esbuild-linux-32 Completed in 5ms
npm timing reifyNode:node_modules/esbuild-freebsd-arm64 Completed in 5ms
npm timing reifyNode:node_modules/esbuild-freebsd-64 Completed in 5ms
npm timing reifyNode:node_modules/esbuild-darwin-arm64 Completed in 5ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-experimental-optdeps/-/esbuild-experimental-optdeps-0.12.3.tgz 58ms
npm timing reifyNode:node_modules/esbuild-experimental-optdeps Completed in 66ms
npm http fetch GET 200 https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.12.3.tgz 460ms
npm timing reifyNode:node_modules/esbuild-darwin-64 Completed in 470ms
npm timing reify:unpack Completed in 471ms
npm timing reify:unretire Completed in 0ms
npm timing build:queue Completed in 1ms
npm timing build:link:node_modules/esbuild-darwin-64 Completed in 3ms
npm timing build:link Completed in 3ms
npm timing build:deps Completed in 4ms
npm timing build Completed in 4ms
npm timing reify:build Completed in 4ms
npm timing reify:trash Completed in 3ms
npm timing reify:save Completed in 5ms
npm timing reify Completed in 1367ms

added 2 packages in 2s
npm timing command:install Completed in 1370ms
npm verb exit 0
npm timing npm Completed in 1535ms
npm info ok
Click to expand the install log for pnpm@6.14.0-0 (✅ only 2 packages are downloaded)
$ echo '{ "dependencies": { "esbuild-experimental-optdeps": "0.12.3" } }' > package.json
$ rm -fr ~/.pnpm-store
$ pnpm install --ignore-scripts
Packages: +2
++
Packages are hard linked from the content-addressable store to the virtual store.
  Content-addressable store is at: /Users/evan/.pnpm-store/v3
  Virtual store is at:             node_modules/.pnpm
Progress: resolved 13, reused 0, downloaded 2, added 2, done

dependencies:
+ esbuild-experimental-optdeps 0.12.3 (0.12.5 is available)
Click to expand the install log for yarn@1.22.11 (⚠️ all 13 packages are downloaded)
$ echo '{ "dependencies": { "esbuild-experimental-optdeps": "0.12.3" } }' > package.json
$ rm -fr ~/Library/Caches/Yarn/v6
$ yarn --ignore-scripts
yarn install v1.22.11
warning package.json: No license field
info No lockfile found.
warning No license field
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
info esbuild-linux-mips64le@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-linux-mips64le@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-linux-mips64le@0.12.3: The CPU architecture "x64" is incompatible with this module.
info esbuild-freebsd-arm64@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-freebsd-arm64@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-freebsd-arm64@0.12.3: The CPU architecture "x64" is incompatible with this module.
info esbuild-linux-ppc64le@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-linux-ppc64le@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-linux-ppc64le@0.12.3: The CPU architecture "x64" is incompatible with this module.
info esbuild-windows-32@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-windows-32@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-windows-32@0.12.3: The CPU architecture "x64" is incompatible with this module.
info esbuild-windows-64@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-windows-64@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-darwin-arm64@0.12.3: The CPU architecture "x64" is incompatible with this module.
info "esbuild-darwin-arm64@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-freebsd-64@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-freebsd-64@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-linux-32@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-linux-32@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-linux-32@0.12.3: The CPU architecture "x64" is incompatible with this module.
info esbuild-linux-arm@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-linux-arm@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-linux-arm@0.12.3: The CPU architecture "x64" is incompatible with this module.
info esbuild-linux-arm64@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-linux-arm64@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
info esbuild-linux-arm64@0.12.3: The CPU architecture "x64" is incompatible with this module.
info esbuild-linux-64@0.12.3: The platform "darwin" is incompatible with this module.
info "esbuild-linux-64@0.12.3" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
warning Ignored scripts due to flag.
success Saved lockfile.
✨  Done in 4.74s.
$ ls ~/Library/Caches/Yarn/v6
npm-esbuild-darwin-64-0.12.3-5cd897232fc7feb59362c2f2ca1ed47175f279cc-integrity
npm-esbuild-darwin-arm64-0.12.3-d77d04164837b306290e3447869ecc95ecbc5a1a-integrity
npm-esbuild-experimental-optdeps-0.12.3-13f338cfe8e9192c3b67898701f30d5c8b7cfeaa-integrity
npm-esbuild-freebsd-64-0.12.3-fd299795033b5a5e6c565ca6a788a53da807d00b-integrity
npm-esbuild-freebsd-arm64-0.12.3-25dbf35a79364b4b6ddfe1311b1efae1992c3161-integrity
npm-esbuild-linux-32-0.12.3-d10c378e74ddbbc27c8aa3f1269cc04b4426482c-integrity
npm-esbuild-linux-64-0.12.3-d33db45acd161294bb4d4397cb06293200963b3f-integrity
npm-esbuild-linux-arm-0.12.3-27842746d80d57cfd5b9a2a7e36615bc8a0a640c-integrity
npm-esbuild-linux-arm64-0.12.3-754b940b9de69f9d2068238c93310a971f3377ad-integrity
npm-esbuild-linux-mips64le-0.12.3-78e04871f540ff9743fc2e225a6d492301b5395b-integrity
npm-esbuild-linux-ppc64le-0.12.3-b698891e97cab395eab38de121520a5de9370145-integrity
npm-esbuild-windows-32-0.12.3-4ab4742a5b04b08d667292cb26f9f219e5f812fd-integrity
npm-esbuild-windows-64-0.12.3-d998f6d90d866618da2c3c7854cefe99f617b771-integrity
Click to expand the install log for yarn@3.0.1 (⚠️ all 13 packages are downloaded)
$ echo '{ "dependencies": { "esbuild-experimental-optdeps": "0.12.3" } }' > package.json
$ touch yarn.lock
$ rm -fr ~/.yarn/berry/cache
$ yarn set version berry
$ yarn
➤ YN0000: ┌ Resolution step
➤ YN0000: └ Completed in 1s 7ms
➤ YN0000: ┌ Fetch step
➤ YN0013: │ esbuild-linux-arm@npm:0.12.3 can't be found in the cache and will be fetched from the remote registry
➤ YN0013: │ esbuild-linux-mips64le@npm:0.12.3 can't be found in the cache and will be fetched from the remote registry
➤ YN0013: │ esbuild-linux-ppc64le@npm:0.12.3 can't be found in the cache and will be fetched from the remote registry
➤ YN0013: │ esbuild-windows-32@npm:0.12.3 can't be found in the cache and will be fetched from the remote registry
➤ YN0013: │ esbuild-windows-64@npm:0.12.3 can't be found in the cache and will be fetched from the remote registry
➤ YN0000: └ Completed in 8s 255ms
➤ YN0000: ┌ Link step
➤ YN0000: └ Completed
➤ YN0000: Done in 9s 393ms
$ ls ~/.yarn/berry/cache
esbuild-darwin-64-npm-0.12.3-3bce268eff-8.zip
esbuild-darwin-arm64-npm-0.12.3-2067324df1-8.zip
esbuild-experimental-optdeps-npm-0.12.3-da58fe24de-8.zip
esbuild-freebsd-64-npm-0.12.3-b28b30bfa6-8.zip
esbuild-freebsd-arm64-npm-0.12.3-5601b669f5-8.zip
esbuild-linux-32-npm-0.12.3-182249b5a7-8.zip
esbuild-linux-64-npm-0.12.3-4067979d42-8.zip
esbuild-linux-arm-npm-0.12.3-91320e7a3b-8.zip
esbuild-linux-arm64-npm-0.12.3-ef73807905-8.zip
esbuild-linux-mips64le-npm-0.12.3-daf3dd1009-8.zip
esbuild-linux-ppc64le-npm-0.12.3-81866b1f7b-8.zip
esbuild-windows-32-npm-0.12.3-778762815e-8.zip
esbuild-windows-64-npm-0.12.3-5ac80b68d1-8.zip

So I think not downloading irrelevant optional dependencies can now be considered expected behavior, and the problem with Yarn can be considered a bug in Yarn. Next steps for this are probably filing this bug with Yarn and at some point moving forward with removing esbuild's install script assuming this installation strategy still makes sense to switch to.

Edit: filed as yarnpkg/berry#3317

Performance notes

This package installation strategy is not perfect because it looks like the metadata for all packages is still downloaded (e.g. in this request), and the metadata is surprisingly not very small (~200kb compressed, ~500kb uncompressed). The total cost of this metadata for all currently-published packages is pretty large (1.5mb compressed, 4.3mb uncompressed). For reference, the full package size for a single OS is about twice as big (~3mb compressed, ~8mb uncompressed) so changing from the install script strategy to the optional dependency strategy will increase the amount of downloaded data by 50% (and will increase it more over time as more esbuild versions are published since each package manifest includes all previously published versions).

Note to self: I'm really surprised that npm's installer works like this where packages get slower to install the more versions they have. No wonder npm is so slow. I wonder if this is an argument for trying to publish each new esbuild version as a new package instead of as a version of an existing package. Then the metadata for each package should only contain one entry instead of all N previous entries. Hmm...

@IgorMinar
Copy link

This is an excellent news! Thanks for sharing the notes @evanw!

@arcanis I looked through the issue tracker and the closest thing I found was yarnpkg/berry#2751 - this does seem to be related, but rather than reusing optional dependencies is proposing a new way to declare platform specific deps. Would yarn consider using optional deps instead in a way similar to how npm and pmp does it now?

@arcanis
Copy link

arcanis commented Aug 19, 2021

Optional dependencies matter for the build (as, make their build allowed to fail) but are still installed. This is the behavior we observed quite a long time ago, and while it never got spec'd I'm not thrilled by the idea of changing its semantic. (edit: more on that in yarnpkg/berry#3317 (comment))

It also doesn't work well for multi-system caching purposes (think osx in dev and Linux in ci), since each system would then only download their copy without being aware of the other that would still need to be in the local cache.

We can keep discussing on the open issue on our repo, but I'll move it from a bug into a feature request.

@evanw
Copy link
Owner

evanw commented Aug 19, 2021

Would yarn consider using optional deps instead in a way similar to how npm and pmp does it now?

I assumed that Yarn has to at least support optional dependencies even if it introduces the package variants feature since many packages in the npm ecosystem already use optional dependencies, and Yarn wants to be compatible with the npm ecosystem. And Yarn does appear to support them, but in a seemingly sub-optimal way that downloads more data than necessary.

It also doesn't work well for multi-system caching purposes (think osx in dev and Linux in ci), since each system would then only download their copy without being aware of the other that would still need to be in the local cache.

That did occur to me too. I was wondering how that works. It looks like npm only downloads the package for the current platform so I assumed npm's cross-platform caching use case had to work out somehow. But maybe npm's caching is actually broken in this area?

Isn't that something that yarnpkg/berry#2751 would have to solve as well though? Or does that proposal still always download all packages for all platforms? If so, then the package variants feature wouldn't be helpful for esbuild's use case after all since I'm trying to avoid always downloading all packages for all platforms. I'm mentioning this since you asked about that proposal in relation to esbuild in #1154.

We can keep discussing on the open issue on our repo, but I'll move it from a bug into a feature request.

Sounds good, makes sense 👍

@evanw
Copy link
Owner

evanw commented Aug 21, 2021

I just ran some more thorough tests on esbuild-experimental-optdeps@0.12.3 with different package managers:

Package manager Cold install time Warm install time JS API works CLI works Downloads extra data Spams irrelevant logs
npm@6.14.14 4.3s 0.8s ⚠️ ⚠️
npm@7.20.3 1.6s 0.5s 🚫
pnpm@6.13.0 4.8s 0.7s 🚫 ⚠️
pnpm@6.14.0-0 1.4s 0.8s 🚫
yarn@1.22.11 6.2s 1.0s ⚠️ ⚠️
yarn@2.4.2 30.1s 1.8s 🚫 ⚠️
yarn@3.0.1-rc.2 10.0s 1.9s 🚫 ⚠️

Some thoughts:

  • Installing with npm@latest and pnpm@next seems like it's working great.

  • Installing with older versions of npm and pnpm is still ok, although it'll unnecessary download lots of extra data. There's nothing I can do about this though.

  • Installing with all versions of yarn is surprisingly slow vs. npm and pnpm even though it should be doing something equivalent (30 seconds vs. 5 seconds which is 6x slower).

  • I need to change the way esbuild's CLI works since it's not in the .bin path when installed with npm/pnpm. Right now it's not working at all which is why you can see "🚫" in the "CLI works" columns.

    One option is to have the esbuild-experimental-optdeps package include a script called bin/esbuild that contains a require.resolve call to the package for the current platform, and then create a child process of the binary executable in that package. That will work. However, it means additional overhead due to the cost of starting up node (at least on non-Windows platforms; starting up node seems unavoidable on Windows because of the esbuild vs. esbuild.exe difference).

    One way to optimize this is to have a postinstall script that copies the binary executable out of the package for the current platform and replaces the bin/esbuild script with the binary executable at install time. This is nice because if you install esbuild with npm install --ignore-scripts, the postinstall script won't run but the package will still work, just more slowly than it would otherwise. In other words, the install script would be optional. The current installation strategy requires an install script and breaks when you pass --ignore-scripts so this would still be an improvement. However, I think adding a postinstall script causes newer versions of yarn to install packages less efficiently since they will be extracted from the package archive so that the install script can run. This is considered undesirable by those who use yarn. I don't see a way to have this both ways, and would probably prioritize npm over yarn here.

To reiterate for clarity: The benefits of this approach vs. the current install script approach are:

  • It will work with --ignore-scripts, which some people enable
  • It should automatically work with all custom package manager settings including custom proxies and/or custom registries
  • It should work offline since the package manager handles all downloads
  • It should work on read-only file systems (with the caveat that it may not work with yarn in this case because the binary executable must be extracted from the package archive in order to be run)

The drawbacks of this approach vs. the current install script approach are:

  • Installs will be slower and more wasteful because they will download more data (anywhere from 1.7x more data in the case of npm and pnpm to 12x more data in the case of yarn)
  • The CLI may run slower in certain scenarios such as --ignore-scripts due to the overhead of an additional node process
  • It won't work with --no-optional, which some people enable
Click to expand the code for the script that generated this table (warning: macOS only, deletes cache directories in your home folder, run at your own risk)
// This is meant to be run on macOS
const child_process = require('child_process')
const fs = require('fs')

function run(command) {
  console.log('$ ' + command)
  child_process.execFileSync('sh', ['-c', command], {
    stdio: 'inherit',
    cwd: __dirname,
  })
}

function runAndCheck(command) {
  console.log('$ ' + command)
  const stdout = child_process.execFileSync('sh', ['-c', command], {
    stdio: ['inherit', 'pipe', 'inherit'],
    cwd: __dirname,
  })
  return stdout.toString()
}

function cleanProject() {
  run('rm -fr package-lock.json node_modules yarn.lock .yarn .yarnrc.yml pnpm-lock.yaml .pnp.cjs .pnpm-debug.log yarn-error.log')
}

function cleanPackageManager() {
  run('rm -fr ~/.pnpm-store ~/Library/Caches/Yarn/v6 ~/.yarn/berry/cache')
  run('npm cache clean --force')
}

function runPackageManager(commands, extras) {
  console.log('-'.repeat(80))

  cleanProject()
  cleanPackageManager()

  fs.writeFileSync(__dirname + '/entry.js',
    `console.log(require('esbuild-experimental-optdeps').transformSync('1+2').code.trim())`)

  fs.writeFileSync(__dirname + '/package.json', JSON.stringify({
    scripts: { 'check': 'esbuild --version' },
    dependencies: { 'esbuild-experimental-optdeps': '0.12.3' },
  }, null, 2))

  const runCommand = commands.pop()
  const installCommand = commands.pop()
  for (const cmd of commands) run(cmd)

  const commandName = installCommand.split(' ')[0]
  const version = commandName + '@' + runAndCheck(commandName + ' --version').trim()

  const beforeColdInstall = Date.now()
  run(installCommand)
  const coldInstallTime = Date.now() - beforeColdInstall

  let warmInstallTime = 0
  for (let i = 0; i < 3; i++) {
    cleanProject()
    const beforeWarmInstall = Date.now()
    run(installCommand)
    warmInstallTime += Date.now() - beforeWarmInstall
  }
  warmInstallTime /= 3

  const output = runAndCheck(runCommand)
  const jsApiWorks = output === '1 + 2;\n'

  let binPathWorks = false
  try {
    run('npm run check')
    binPathWorks = true
  } catch (e) {
  }

  return {
    version,
    coldInstallTime,
    warmInstallTime,
    jsApiWorks,
    binPathWorks,
    downloadAll: false,
    logSpam: false,
    ...extras,
  }
}

const packageManagerRunners = {
  npm6: () => runPackageManager([
    'npm i -g npm@6.14.14',
    'npm install',
    'node entry.js',
  ], {
    downloadAll: true,
    logSpam: true,
  }),

  npm7: () => runPackageManager([
    'npm i -g npm@7.20.3',
    'npm install',
    'node entry.js',
  ], {
  }),

  pnpmLatest: () => runPackageManager([
    'npm i -g pnpm@6.13.0',
    'pnpm install',
    'node entry.js',
  ], {
    downloadAll: true,
  }),

  pnpmNext: () => runPackageManager([
    'npm i -g pnpm@6.14.0-0',
    'pnpm install',
    'node entry.js',
  ], {
  }),

  yarn1: () => runPackageManager([
    'yarn',
    'node entry.js',
  ], {
    downloadAll: true,
    logSpam: true,
  }),

  yarn2: () => runPackageManager([
    'yarn set version berry',
    'yarn set version 2',
    'yarn',
    'yarn node entry.js',
  ], {
    downloadAll: true,
  }),

  yarn3: () => runPackageManager([
    'yarn set version berry',
    'yarn set version 3',
    'yarn',
    'yarn node entry.js',
  ], {
    downloadAll: true,
  }),
}

const columns = {
  version: 'Package manager',
  coldInstallTime: 'Cold install time',
  warmInstallTime: 'Warm install time',
  jsApiWorks: 'JS API works',
  binPathWorks: 'CLI works',
  downloadAll: 'Downloads extra data',
  logSpam: 'Spams irrelevant logs',
}

const table = []

function addRow(row) {
  console.log(row)
  table.push(row)
}

addRow('| ' + Object.values(columns).join(' | ') + ' |')
addRow('|' + Object.values(columns).map(() => '---').join('|') + '|')

for (const name in packageManagerRunners) {
  const runner = packageManagerRunners[name]
  const result = runner()
  result.version = '`' + result.version + '`'
  result.coldInstallTime = `${(result.coldInstallTime / 1000).toFixed(1)}s`
  result.warmInstallTime = `${(result.warmInstallTime / 1000).toFixed(1)}s`
  result.jsApiWorks = result.jsApiWorks ? '✅' : '🚫'
  result.binPathWorks = result.binPathWorks ? '✅' : '🚫'
  result.downloadAll = result.downloadAll ? '⚠️' : '✅'
  result.logSpam = result.logSpam ? '⚠️' : '✅'
  addRow('| ' + Object.keys(columns).map(k => result[k]).join(' | ') + ' |')
}

cleanProject()
console.log(table.join('\n'))

@stefanlivens
Copy link

Hi, just want to chime in, we're evaluating vite on a Jenkins CI/CD server, that has no access to the internet directly, only via a "npm-registry-proxy". So naturally npm installing vite gives an error:

14:37:57 Trying to install "esbuild-windows-64" using npm
14:37:57 Failed to install "esbuild-windows-64" using npm: Command failed: npm install --loglevel=error --prefer-offline --no-audit --progress=false esbuild-windows-64@0.12.24
14:37:57 npm ERR! cb() never called!
14:37:57
14:37:57 npm ERR! This is an error with npm itself. Please report this error at:
14:37:57 npm ERR! https://npm.community
14:37:57
14:37:57 npm ERR! A complete log of this run can be found in:
14:37:57 npm ERR! C:\Users<user>\AppData\Roaming\npm-cache_logs\2021-09-02T12_37_52_822Z-debug.log
14:37:57
14:37:57 Trying to download "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.12.24.tgz"
14:38:15 Failed to download "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.12.24.tgz": connect ETIMEDOUT :443
14:38:15 Install unsuccessful

But testing with esbuild-experimental-optdeps@0.12.2 and 0.12.3 this works (installs without errors on our Jenkins CI/CD server, so that way of 'packaging' works)

Any ideas on how to make this work with vite ?

Thanks in advance!

@vgpechenkin
Copy link

Hi, @evanw.
Do you have a plan to push your experiments to master?

@evanw
Copy link
Owner

evanw commented Sep 20, 2021

Do you have a plan to push your experiments to master?

Yes.

Edit: The code for this is here: #1621.

@evanw
Copy link
Owner

evanw commented Sep 21, 2021

The PR #1621 implements this installation strategy and I believe I have fixed all issues. A test package has been published as esbuild-experimental-optdeps@0.12.10 and should represent the state of the esbuild package after this PR lands. I plan to land this PR sometime soon and publish a new breaking change version (so version 0.13.0, bumping the minor version number).

Here are the latest results of my tests across various package managers:

Package manager Cold install time Warm install time JS API works CLI works Downloads extra data Spams irrelevant logs
npm@6.14.15 5.4s 1.0s ⚠️ ⚠️
npm@7.24.0 1.5s 0.7s
pnpm@6.13.0 4.4s 0.8s ⚠️
pnpm@6.15.1 1.4s 0.8s
yarn@1.22.11 4.3s 0.9s ⚠️ ⚠️
yarn@2.4.3 34.1s 1.4s ⚠️
yarn@3.1.0-rc.5 10.3s 1.4s ⚠️

Obviously yarn 2+ is doing something very inefficient but I have filed yarnpkg/berry#3317 about this and this is something yarn needs to fix. There isn't anything I can do about this on my end. At least warm install times (i.e. when that version of esbuild has already been installed once) aren't too bad.

Click to expand the code for the script that generated this table (warning: macOS only, deletes cache directories in your home folder, run at your own risk)
// This is meant to be run on macOS
const child_process = require('child_process')
const fs = require('fs')

function run(command) {
  console.log('$ ' + command)
  child_process.execFileSync('sh', ['-c', command], {
    stdio: 'inherit',
    cwd: __dirname,
  })
}

function runAndCheck(command) {
  console.log('$ ' + command)
  const stdout = child_process.execFileSync('sh', ['-c', command], {
    stdio: ['inherit', 'pipe', 'inherit'],
    cwd: __dirname,
  })
  return stdout.toString()
}

function cleanProject() {
  run('rm -fr package-lock.json node_modules yarn.lock .yarn .yarnrc.yml pnpm-lock.yaml .pnp.cjs .pnpm-debug.log yarn-error.log')
}

function cleanPackageManager() {
  run('rm -fr ~/.pnpm-store ~/Library/Caches/Yarn/v6 ~/.yarn/berry/cache')
  run('npm cache clean --force')
}

function runPackageManager(options) {
  console.log('-'.repeat(80))

  cleanProject()
  cleanPackageManager()

  fs.writeFileSync(__dirname + '/entry.js',
    `console.log(require('esbuild-experimental-optdeps').transformSync('1+2').code.trim())`)

  fs.writeFileSync(__dirname + '/package.json', JSON.stringify({
    scripts: { 'check': 'esbuild --version' },
    dependencies: { 'esbuild-experimental-optdeps': '0.12.10' },
  }, null, 2))

  for (const cmd of options.before) run(cmd)

  const commandName = options.install.split(' ')[0]
  const version = commandName + '@' + runAndCheck(commandName + ' --version').trim()

  const beforeColdInstall = Date.now()
  run(options.install)
  const coldInstallTime = Date.now() - beforeColdInstall

  let warmInstallTime = 0
  for (let i = 0; i < 3; i++) {
    cleanProject()
    for (const cmd of options.before) run(cmd)
    const beforeWarmInstall = Date.now()
    run(options.install)
    warmInstallTime += Date.now() - beforeWarmInstall
  }
  warmInstallTime /= 3

  const output = runAndCheck(options.run)
  const jsApiWorks = output === '1 + 2;\n'

  let binPathWorks = false
  try {
    run(options.check)
    binPathWorks = true
  } catch (e) {
  }

  return {
    version,
    coldInstallTime,
    warmInstallTime,
    jsApiWorks,
    binPathWorks,
    downloadAll: false,
    logSpam: false,
    ...options,
  }
}

const packageManagerRunners = {
  npm6: () => runPackageManager({
    before: [
      'npm i -g npm@6.14.15',
    ],
    install: 'npm install',
    run: 'node entry.js',
    check: 'npm run check',
    downloadAll: true,
    logSpam: true,
  }),

  npm7: () => runPackageManager({
    before: [
      'npm i -g npm@7.24.0',
    ],
    install: 'npm install',
    run: 'node entry.js',
    check: 'npm run check',
  }),

  pnpmLatest: () => runPackageManager({
    before: [
      'npm i -g pnpm@6.13.0',
    ],
    install: 'pnpm install',
    run: 'node entry.js',
    check: 'pnpm run check',
    downloadAll: true,
  }),

  pnpmNext: () => runPackageManager({
    before: [
      'npm i -g pnpm@6.15.1',
    ],
    install: 'pnpm install',
    run: 'node entry.js',
    check: 'pnpm run check',
  }),

  yarn1: () => runPackageManager({
    before: [
    ],
    install: 'yarn',
    run: 'node entry.js',
    check: 'yarn run check',
    downloadAll: true,
    logSpam: true,
  }),

  yarn2: () => runPackageManager({
    before: [
      'yarn set version berry',
      'yarn set version 2',
    ],
    install: 'yarn',
    run: 'yarn node entry.js',
    check: 'yarn run check',
    downloadAll: true,
  }),

  yarn3: () => runPackageManager({
    before: [
      'yarn set version berry',
      'yarn set version 3',
    ],
    install: 'yarn',
    run: 'yarn node entry.js',
    check: 'yarn run check',
    downloadAll: true,
  }),
}

const columns = {
  version: 'Package manager',
  coldInstallTime: 'Cold install time',
  warmInstallTime: 'Warm install time',
  jsApiWorks: 'JS API works',
  binPathWorks: 'CLI works',
  downloadAll: 'Downloads extra data',
  logSpam: 'Spams irrelevant logs',
}

const table = []

function addRow(row) {
  console.log(row)
  table.push(row)
}

addRow('| ' + Object.values(columns).join(' | ') + ' |')
addRow('|' + Object.values(columns).map(() => '---').join('|') + '|')

for (const name in packageManagerRunners) {
  const runner = packageManagerRunners[name]
  const result = runner()
  result.version = '`' + result.version + '`'
  result.coldInstallTime = `${(result.coldInstallTime / 1000).toFixed(1)}s`
  result.warmInstallTime = `${(result.warmInstallTime / 1000).toFixed(1)}s`
  result.jsApiWorks = result.jsApiWorks ? '✅' : '🚫'
  result.binPathWorks = result.binPathWorks ? '✅' : '🚫'
  result.downloadAll = result.downloadAll ? '⚠️' : '✅'
  result.logSpam = result.logSpam ? '⚠️' : '✅'
  addRow('| ' + Object.keys(columns).map(k => result[k]).join(' | ') + ' |')
}

cleanProject()
console.log(table.join('\n'))

@yisibl
Copy link
Contributor

yisibl commented Sep 24, 2021

Starting from cnpm@7.1.0, no additional data will be downloaded, and there will be no warnings 🎉.

I added cnpm data:

Package manager Cold install time Warm install time JS API works CLI works Downloads extra data Spams irrelevant logs
cnpm@7.0.0 6.4s 4.2s ⚠️ ⚠️
cnpm@7.1.0 2.9s 2.2s
npm@6.14.15 5.4s 1.0s ⚠️ ⚠️
npm@7.24.0 1.5s 0.7s
pnpm@6.13.0 4.4s 0.8s ⚠️
pnpm@6.15.1 1.4s 0.8s
yarn@1.22.11 4.3s 0.9s ⚠️ ⚠️
yarn@2.4.3 34.1s 1.4s ⚠️
yarn@3.1.0-rc.5 10.3s 1.4s ⚠️
yarn@3.1.0 10.3s 1.4s

How to install cnpm?

npm install -g cnpm --registry=https://registry.npmmirror.com
cnpm i esbuild

du -sh node_modules/
7.4M  node_modules/  (before: 118M)

Thanks to my colleagues for their efforts, see also:

2021-10-26

  • yarn 3.1 will skip fetching and installing those packages unless they match the current system parameters.

@merceyz
Copy link

merceyz commented Oct 26, 2021

yarn@>=3.1 will skip the download and install for packages that doesn't match the current system and yarn@^1.22.17 wont log messages about them anymore

@panoply
Copy link

panoply commented May 5, 2022

Just wanted to mention that for those of you getting here and using pnpm you can simply run pnpm rebuild and the binary will rebuild from post-install.

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 a pull request may close this issue.