Permalink
Browse files

Rack::StaticCache - auto cache headers for URLs with asset timestamps

Modifies response headers for client and proxy caching of static
files that minimizes http requests and improves overall load times
for second time visitors.

Signed-off-by: Ryan Tomayko <rtomayko@gmail.com>
  • Loading branch information...
1 parent b67459b commit 3f42d3afe7323d322567d77cd404fb5bd8d9f1eb @anupom anupom committed with rtomayko May 26, 2009
Showing with 189 additions and 0 deletions.
  1. +2 −0 README.rdoc
  2. +1 −0 lib/rack/contrib.rb
  3. +93 −0 lib/rack/contrib/static_cache.rb
  4. +1 −0 test/documents/test
  5. +91 −0 test/spec_rack_static_cache.rb
  6. +1 −0 test/statics/test
View
@@ -25,6 +25,8 @@ interface:
from file.
* Rack::Signals - Installs signal handlers that are safely processed after
a request
+* Rack::StaticCache - Modifies the response headers to facilitiate client and proxy caching for
+ static files that minimizes http requests and improves overall load times for second time visitors.
* Rack::TimeZone - Detects the clients timezone using JavaScript and sets
a variable in Rack's environment with the offset from UTC.
* Rack::Evil - Lets the rack application return a response to the client from any place.
View
@@ -31,4 +31,5 @@ def self.release
autoload :NotFound, "rack/contrib/not_found"
autoload :ResponseCache, "rack/contrib/response_cache"
autoload :RelativeRedirect, "rack/contrib/relative_redirect"
+ autoload :StaticCache, "rack/contrib/static_cache"
end
@@ -0,0 +1,93 @@
+module Rack
+
+ #
+ # The Rack::StaticCache middleware automatically adds, removes and modifies
+ # stuffs in response headers to facilitiate client and proxy caching for static files
+ # that minimizes http requests and improves overall load times for second time visitors.
+ #
+ # Once a static content is stored in a client/proxy the only way to enforce the browser
+ # to fetch the latest content and ignore the cache is to rename the static file.
+ #
+ # Alternatively, we can add a version number into the URL to the content to bypass
+ # the caches. Rack::StaticCache by default handles version numbers in the filename.
+ # As an example,
+ # http://yoursite.com/images/test-1.0.0.png and http://yoursite.com/images/test-2.0.0.png
+ # both reffers to the same image file http://yoursite.com/images/test.png
+ #
+ # Another way to bypass the cache is adding the version number in a field-value pair in the
+ # URL query string. As an example, http://yoursite.com/images/test.png?v=1.0.0
+ # In that case, set the option :versioning to false to avoid unneccessary regexp calculations.
+ #
+ # It's better to keep the current version number in some config file and use it in every static
+ # content's URL. So each time we modify our static contents, we just have to change the version
+ # number to enforce the browser to fetch the latest content.
+ #
+ # You can use Rack::Deflater along with Rack::StaticCache for further improvements in page loading time.
+ #
+ # Examples:
+ # use Rack::StaticCache, :urls => ["/images", "/css", "/js", "/documents*"], :root => "statics"
+ # will serve all requests beginning with /images, /csss or /js from the
+ # directory "statics/images", "statics/css", "statics/js".
+ # All the files from these directories will have modified headers to enable client/proxy caching,
+ # except the files from the directory "documents". Append a * (star) at the end of the pattern
+ # if you want to disable caching for any pattern . In that case, plain static contents will be served with
+ # default headers.
+ #
+ # use Rack::StaticCache, :urls => ["/images"], :duration => 2, :versioning => false
+ # will serve all requests begining with /images under the current directory (default for the option :root
+ # is current directory). All the contents served will have cache expiration duration set to 2 years in headers
+ # (default for :duration is 1 year), and StaticCache will not compute any versioning logics (default for
+ # :versioning is true)
+ #
+
+
+ class StaticCache
+
+ def initialize(app, options={})
+ @app = app
+ @urls = options[:urls]
+ @no_cache = {}
+ @urls.collect! do |url|
+ if url =~ /\*$/
+ url.sub!(/\*$/, '')
+ @no_cache[url] = 1
+ end
+ url
+ end
+ root = options[:root] || Dir.pwd
+ @file_server = Rack::File.new(root)
+ @cache_duration = options[:duration] || 1
+ @versioning_enabled = true
+ @versioning_enabled = options[:versioning] unless options[:versioning].nil?
+ @duration_in_seconds = self.duration_in_seconds
+ @duration_in_words = self.duration_in_words
+ end
+
+ def call(env)
+ path = env["PATH_INFO"]
+ url = @urls.detect{ |u| path.index(u) == 0 }
+ unless url.nil?
+ path.sub!(/-[\d.]+([.][a-zA-Z][\w]+)?$/, '\1') if @versioning_enabled
+ status, headers, body = @file_server.call(env)
+ if @no_cache[url].nil?
+ headers['Cache-Control'] ="max-age=#{@duration_in_seconds}, public"
+ headers['Expires'] = @duration_in_words
+ headers.delete 'Etag'
+ headers.delete 'Pragma'
+ headers.delete 'Last-Modified'
@jgyllen

jgyllen Oct 6, 2011

Why are Etag and Last-Modified removed?

@lgierth

lgierth Oct 6, 2011

I guess to be compliant with this:

> Once a static content is stored in a client/proxy the only way to enforce the browser
> to fetch the latest content and ignore the cache is to rename the static file.
@jgyllen

jgyllen Oct 6, 2011

Why wouldn't that work if you supply the above headers? What documentation are you referring to?

If the client/proxy doesn't have a copy of the new file, it will just get it, no?

@anupom

anupom Nov 18, 2011

Contributor

I have at least tons of documentation to refer to http://lmgtfy.com/?q=remove+etag+last-modified

@ericboehs

ericboehs Jan 16, 2014

Contributor

I think this is in error. Being able to bypass the proxy to force a refresh isn't possible with Etag/Last-Modified headers; this is intended.

Etag and Last-Modified headers can and should be used in combination with Cache-Control or Expires headers. You can instruct the user's browser that "this file is good for at least a day before you need to check back in". When it checks back in in a day, it will just hit your proxy as the Etag or Last-Modified will be the same.

The deletions here remove a feature (server-side caching) that isn't related to the feature being implemented (client side caching). If you need to update your proxies you push new files to them (which should change the etag/last-modified).

Google states "It is important to specify one of Expires or Cache-Control max-age, and one of Last-Modified or ETag, for all cacheable resources". They even dock your score on their PageSpeed tool for it.

I'd recommend leaving these in place. If the developer has Etag/Last-Modified headers, we shouldn't assume he didn't mean to do that.

@ericboehs

ericboehs Jan 16, 2014

Contributor

I have created a PR to revert this: #84.

+ end
+ [status, headers, body]
+ else
+ @app.call(env)
+ end
+ end
+
+ def duration_in_words
+ (Time.now + self.duration_in_seconds).strftime '%a, %d %b %Y %H:%M:%S GMT'
+ end
+
+ def duration_in_seconds
+ 60 * 60 * 24 * 365 * @cache_duration
+ end
+ end
+end
View
@@ -0,0 +1 @@
+nocache
@@ -0,0 +1,91 @@
+require 'test/spec'
+
+require 'rack'
+require 'rack/contrib/static_cache'
+require 'rack/mock'
+
+class DummyApp
+ def call(env)
+ [200, {}, ["Hello World"]]
+ end
+end
+
+describe "Rack::StaticCache" do
+
+ setup do
+ @root = ::File.expand_path(::File.dirname(__FILE__))
+ end
+
+ it "should serve files with required headers" do
+ default_app_request
+ res = @request.get("/statics/test")
+ res.should.be.ok
+ res.body.should =~ /rubyrack/
+ res.headers['Cache-Control'].should == 'max-age=31536000, public'
+ next_year = Time.now().year + 1
+ res.headers['Expires'].should =~ Regexp.new(
+ "[A-Z][a-z]{2}[,][\s][0-9]{2}[\s][A-Z][a-z]{2}[\s]" << "#{next_year}" <<
+ "[\s][0-9]{2}[:][0-9]{2}[:][0-9]{2} GMT$")
+ res.headers.has_key?('Etag').should == false
+ res.headers.has_key?('Pragma').should == false
+ res.headers.has_key?('Last-Modified').should == false
+ end
+
+ it "should return 404s if url root is known but it can't find the file" do
+ default_app_request
+ res = @request.get("/statics/foo")
+ res.should.be.not_found
+ end
+
+ it "should call down the chain if url root is not known" do
+ default_app_request
+ res = @request.get("/something/else")
+ res.should.be.ok
+ res.body.should == "Hello World"
+ end
+
+ it "should serve files if requested with version number and versioning is enabled" do
+ default_app_request
+ res = @request.get("/statics/test-0.0.1")
+ res.should.be.ok
+ end
+
+ it "should change cache duration if specified thorugh option" do
+ configured_app_request
+ res = @request.get("/statics/test")
+ res.should.be.ok
+ res.body.should =~ /rubyrack/
+ next_next_year = Time.now().year + 2
+ res.headers['Expires'].should =~ Regexp.new("#{next_next_year}")
+ end
+
+ it "should return 404s if requested with version number but versioning is disabled" do
+ configured_app_request
+ res = @request.get("/statics/test-0.0.1")
+ res.should.be.not_found
+ end
+
+ it "should serve files with plain headers when * is added to the directory name" do
+ configured_app_request
+ res = @request.get("/documents/test")
+ res.should.be.ok
+ res.body.should =~ /nocache/
+ next_next_year = Time.now().year + 2
+ res.headers['Expires'].should.not =~ Regexp.new("#{next_next_year}")
+ end
+
+ def default_app_request
+ @options = {:urls => ["/statics"], :root => @root}
+ request
+ end
+
+ def configured_app_request
+ @options = {:urls => ["/statics", "/documents*"], :root => @root, :versioning => false, :duration => 2}
+ request
+ end
+
+ def request
+ @request = Rack::MockRequest.new(Rack::StaticCache.new(DummyApp.new, @options))
+ end
+
+end
View
@@ -0,0 +1 @@
+rubyrack

0 comments on commit 3f42d3a

Please sign in to comment.