documentcloud / jammit

Industrial Strength Asset Packaging for Rails
http://documentcloud.github.com/jammit/
MIT License
1.16k stars 197 forks source link

Write assets to ./tmp #130

Closed yfeldblum closed 13 years ago

yfeldblum commented 13 years ago

Feature suggestion: An easy way to configure Jammit to write JS assets to tmp/javascripts and CSS assets to tmp/stylesheets.

It's super easy to set up a Rack::Static middleware in Rails 3 that gets files from tmp. The cache in front of the Rails app would be responsible for caching and serving the assets. The obvious use case here is Heroku, and packaging assets on application startup after a new deploy.

Note that this use case on Heroku would also require using UglifyJS. But with the therubyracer-heroku and uglifier gems, that's not a problem on Heroku. You can alias compress to compile in Uglifier (or a subclass thereof) so that it's interface-compatible with what Jammit expects.

Note that the key details of Heroku here are: read-only filesystem except for tmp which is mounted as a read-write filesystem, an http cache (Varnish) in front of every application configured automatically, and lack of a JVM. I consider the first two to be best practices (and the third an implementation detail), and I also think that packaging assets on application startup after a new deploy is a good technique for keeping build artifacts out of the repository.

Cheers!

jimmycuadra commented 13 years ago

I would love to see this as well. Asset packaging on Heroku is a very tricky subject, as evidenced by the multitude of gems and plugins which have attempted to solve this problem. Jammit seems to be the best of the asset packagers, but the best way to get it to work cleanly with Heroku is still not very clear. Every article/discussion I've seen on the subject suggests precaching and committing to the repository, which just feels wrong to me, and everyone seems to hack together a solution in a different way.

What yfeldblum suggests here is ideal: creating the cached packages to /tmp automatically on application startup (not relying on Jammit::Controller), and documenation about how to link to those cached packages (which exist above the public document root) with a URL (either directly or via Jammit's helpers).

Another possible solution that occurred to me: Give Jammit a configuration option to package assets but not write them to disk. If my understanding is correct, the first time a URL like /assets/application.js?12345 is accessed, it's routed to Jammit::Controller, which responds with the package and then writes it to disk for subsequent requests. If that second step was omitted, and far-future expiration headers were sent with the direct response, you could rely on Varnish to serve subsequent requests.

Varnish is cleared each time you deploy to Heroku, but if I'm understanding mtime-based cache busting correctly, the query string would change after a deployment anyway, so using Varnish wouldn't result in more downloading of unchanged content than the current mechanism does.

Forcing the browser to download the package again even if its contents has not changed is a separate issue altogether. Ideally Jammit would also support MD5-based cache busting strings which only change when the contents of the package change. That would probably create a problem with the helper methods, though, because in order to link to the asset file, you need the MD5 for the query string, and you couldn't get the MD5 of the package since the package doesn't exist on disk. I'm not sure how that would be solved.

Please correct me if I'm off base with any of this.

yfeldblum commented 13 years ago

On Heroku, we would serve files in tmp/assets with Rack::Static or something else (e.g., with ETag support and stripping out the Last-Modified, because it might be OK to make one or two extra HTTP roundtrips to find out whether one or two whole package have changed, it's just not OK to make dozens of such roundtrips to find out whether dozens of individual scripts/styles have changed). We would set this up in a config/initializers/jammit.rb, very simple, no need for a Jammit controller. All packages would be re-compiled to tmp/assets, and all caches automatically cleared, on application every re-deploy in production on Heroku.

IMO this is all best practice anyway (read-only filesystem with a writable filesystem mounted at tmp, no build artifacts in git, build at application re-deploy in production, enable ETag caching for packages, and set up a good http cache in front of your app).

But to make this happen, Jammit needs to able to be configured to write packages to tmp/assets.

Cheers!

rubiii commented 13 years ago

i'd love to see this happening!

jashkenas commented 13 years ago

Is there someone here that uses Heroku that wants to contribute a patch for this feature?

jimmycuadra commented 13 years ago

I saw that support for UglifyJS was merged. What is the status of therubyracer support on Heroku? That still seems like a prerequisite to do this cleanly.

jashkenas commented 13 years ago

According to that ticket, the recent versions of the uglifier gem have explicit Heroku support.

yfeldblum commented 13 years ago

The uglifier gem still doesn't work right on Heroku for me because it has .gemspec dependencies on therubyracer and therubyracer-heroku.

This is what it does:

Gem::Specification.new do |s|
  if Gem.available?("therubyracer-heroku")
    s.add_runtime_dependency(%q<therubyracer-heroku>, ["~> 0.8.0"])
  else
    s.add_runtime_dependency(%q<therubyracer>, ["~> 0.8.0"])
  end
end

Bad. I build my Gemfile on a dev machine which doesn't have therubyracer-heroku. So my Gemfile.lock has a dependency on therubyracer, which doesn't build on Heroku. And I don't want to have to add custom steps to setup a dev machine just for this odd .gemspec which shouldn't be doing this anyway.

In that ticket, I recommended using the execjs gem which knows how to find and load whatever JavaScript runtimes are installed, without itself having an explicit dependency on any of them. That would be ideal.

Cheers!

jashkenas commented 13 years ago

Ugh, that's a shame. Still, it seems like if you aren't using Bundler, you won't have any problems with uglifier, so that's something at least.

yfeldblum commented 13 years ago

If people who want this can +1 my uglifier ticket, that would be great.

heikki commented 13 years ago

@yfeldblum: which ticket are you referring to?

A bit hacky way that seems to work atm; use therubyracer-heroku locally:

gem "therubyracer-heroku", "0.8.1.pre3"
gem "jammit", :git => "git://github.com/documentcloud/jammit.git"

After bundle install delete therubyracer gem references from the Gemfile.lock, commit changes and push to heroku.

yfeldblum commented 13 years ago

@heikki

This way depends on the absence of therubyracer on every developer's development box, and on all developers paying attention to this issue, or things start breaking. It's a neat workaround before a good fix is implemented (use execjs) - but it's very hacky and depending on the absence of something is not a real solution.

It's best to put optional dependencies (or dependencies that may be satisfied by one of multiple gems) in the README, not in the .gemspec.

Ticket: https://github.com/lautis/uglifier/issues/closed#issue/1

Cheers!

heikki commented 13 years ago

Yes, its ugly as hell :). I needed a way to have recent Jammit haml.js support and be able to deploy to Heroku. Voted for the ticket.

jashkenas commented 13 years ago

I've merged @juggy's soft-dependencies branch, so neither uglifier nor therubyracer should be required now -- is there anything more that needs to be addressed/patched in this ticket?

50a85d0fc06be5d9f7a3484179ef1a14908b6ad8

jashkenas commented 13 years ago

Reading through this ticket again -- I think you should be able to get the desired behavior now by:

  1. Using uglifier.
  2. Setting Rails' cache directory to somewhere within "tmp".
yfeldblum commented 13 years ago

Uglifier was just updated fixing this issue. Thanks for your +1s!