-
-
Notifications
You must be signed in to change notification settings - Fork 159
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add gzip for assets as well as server responses (#983)
* added static_compression_handler * fixed a couple typos in static_compression_handler_spec * actually test value of etag in static_compression_handler * add gzipping to Lucky::TextResponse * add gzip setting to Lucky::Server * integrate static compression handler with dynamic compression handler * dry up static compression handler a little * Handle content types with additional information * add application/javascript to gzip_content_types default setting * added some docs for compression * Update src/lucky/static_compression_handler.cr Add explicit return type for should_compress? Co-Authored-By: Paul Smith <[email protected]> * static_compression_handler doc clarification * fix order of spec * clarified StaticCompressionHandler specs * add spec for not gzipping based on content type * tweak static compression handler spec
- Loading branch information
1 parent
cc119d2
commit a6bd331
Showing
6 changed files
with
272 additions
and
2 deletions.
There are no files selected for viewing
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
require "../spec_helper" | ||
require "http" | ||
|
||
include ContextHelper | ||
|
||
private PATH = "example.css" | ||
|
||
describe Lucky::StaticCompressionHandler do | ||
it "calls next when not enabled" do | ||
context = build_context(path: PATH) | ||
context.request.headers["Accept-Encoding"] = "gzip" | ||
next_called = false | ||
|
||
call_handler_with(context) { next_called = true } | ||
|
||
next_called.should be_true | ||
context.response.headers["Content-Encoding"]?.should_not eq "gzip" | ||
end | ||
|
||
it "calls next when content type isn't in Lucky::Server.gzip_content_types" do | ||
Lucky::Server.temp_config(gzip_enabled: true, gzip_content_types: %w(text/html)) do | ||
context = build_context(path: PATH) | ||
context.request.headers["Accept-Encoding"] = "gzip" | ||
next_called = false | ||
|
||
call_handler_with(context) { next_called = true } | ||
|
||
next_called.should be_true | ||
context.response.headers["Content-Encoding"]?.should_not eq "gzip" | ||
end | ||
end | ||
|
||
it "delivers the precompressed file when enabled" do | ||
Lucky::Server.temp_config(gzip_enabled: true) do | ||
output = IO::Memory.new | ||
context = build_context_with_io(output, path: PATH) | ||
|
||
context.request.method = "GET" | ||
context.request.headers["Accept-Encoding"] = "gzip" | ||
|
||
next_called = false | ||
call_handler_with(context) { next_called = true } | ||
|
||
next_called.should be_false | ||
|
||
context.response.headers["Content-Encoding"].should eq "gzip" | ||
context.response.headers["Etag"].should eq etag | ||
output.close | ||
output.to_s.ends_with?(File.read(gzip_path)).should be_true | ||
end | ||
end | ||
|
||
it "calls next when Accept-Encoding doesn't include gzip" do | ||
Lucky::Server.temp_config(gzip_enabled: true) do | ||
context = build_context(path: PATH) | ||
context.request.headers["Accept-Encoding"] = "whatever" | ||
next_called = false | ||
|
||
call_handler_with(context) { next_called = true } | ||
|
||
next_called.should be_true | ||
context.response.headers["Content-Encoding"]?.should_not eq "gzip" | ||
end | ||
end | ||
|
||
it "sends NOT_MODIFIED when file hasn't been modified" do | ||
Lucky::Server.temp_config(gzip_enabled: true) do | ||
first_context = build_context(path: PATH) | ||
first_context.request.method = "GET" | ||
first_context.request.headers["Accept-Encoding"] = "gzip" | ||
|
||
call_handler_with(first_context) { } | ||
|
||
last_modified = HTTP.parse_time(first_context.response.headers["Last-Modified"]).not_nil! | ||
|
||
context = build_context(path: PATH) | ||
context.request.headers["Accept-Encoding"] = "gzip" | ||
context.request.headers["If-Modified-Since"] = HTTP.format_time(last_modified + 1.hour) | ||
next_called = false | ||
|
||
call_handler_with(context) { next_called = true } | ||
|
||
next_called.should be_false | ||
context.response.status.should eq HTTP::Status::NOT_MODIFIED | ||
end | ||
end | ||
end | ||
|
||
private def public_dir | ||
File.expand_path("spec/fixtures") | ||
end | ||
|
||
private def gzip_path | ||
File.join(public_dir, "#{PATH}.gz") | ||
end | ||
|
||
private def etag | ||
%{W/"#{last_modified.to_unix}"} | ||
end | ||
|
||
private def last_modified | ||
File.info(gzip_path).modification_time | ||
end | ||
|
||
private def call_handler_with(context : HTTP::Server::Context, &block) | ||
handler = Lucky::StaticCompressionHandler.new(public_dir: public_dir) | ||
handler.next = ->(_ctx : HTTP::Server::Context) { block.call } | ||
handler.call(context) | ||
context.response.close | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,27 @@ | ||
# Class for configuring server settings | ||
# | ||
# The settings created here can be customized in each Lucky app by modifying them in your config/server.cr | ||
class Lucky::Server | ||
Habitat.create do | ||
setting secret_key_base : String | ||
setting host : String | ||
setting port : Int32 | ||
setting asset_host : String = "" | ||
setting gzip_enabled : Bool = false | ||
setting gzip_content_types : Array(String) = %w( | ||
application/json | ||
application/javascript | ||
application/xml | ||
font/otf | ||
font/ttf | ||
font/woff | ||
font/woff2 | ||
image/svg+xml | ||
text/css | ||
text/csv | ||
text/html | ||
text/javascript | ||
text/plain | ||
) | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
# Middleware that serves static files that have been pre-compressed. | ||
# There can be multiple instances and the first in the middleware stack will take precedence. | ||
# For example, if you want to serve brotli compressed assets for browsers that support it and | ||
# serve gzip assets for those that don't you would do something like this in your middleware | ||
# in `src/app_server.cr`: | ||
# | ||
# ``` | ||
# [ | ||
# # ... | ||
# Lucky::StaticCompressionHandler.new("./public", file_ext: "br", content_encoding: "br"), | ||
# Lucky::StaticCompressionHandler.new("./public", file_ext: "gz", content_encoding: "gzip"), | ||
# # ... | ||
# ] | ||
# ``` | ||
class Lucky::StaticCompressionHandler | ||
include HTTP::Handler | ||
|
||
def initialize(@public_dir : String, @file_ext = "gz", @content_encoding = "gzip") | ||
end | ||
|
||
def call(context) | ||
original_path = context.request.path.not_nil! | ||
request_path = URI.decode(original_path) | ||
expanded_path = File.expand_path(request_path, "/") | ||
file_path = File.join(@public_dir, expanded_path) | ||
compressed_path = "#{file_path}.#{@file_ext}" | ||
content_type = MIME.from_filename(file_path, "application/octet-stream") | ||
|
||
if !should_compress?(file_path, content_type, compressed_path, context.request.headers) | ||
call_next(context) | ||
return | ||
end | ||
|
||
context.response.headers["Content-Encoding"] = @content_encoding | ||
|
||
last_modified = modification_time(compressed_path) | ||
add_cache_headers(context.response.headers, last_modified) | ||
|
||
if cache_request?(context, last_modified) | ||
context.response.status = :not_modified | ||
return | ||
end | ||
|
||
context.response.content_type = content_type | ||
context.response.content_length = File.size(compressed_path) | ||
File.open(compressed_path) do |file| | ||
IO.copy(file, context.response) | ||
end | ||
end | ||
|
||
private def should_compress?(file_path, content_type, compressed_path, request_headers) : Bool | ||
Lucky::Server.settings.gzip_enabled && | ||
request_headers.includes_word?("Accept-Encoding", @content_encoding) && | ||
Lucky::Server.settings.gzip_content_types.any? { |ct| content_type.starts_with?(ct) } && | ||
File.exists?(compressed_path) | ||
end | ||
|
||
private def add_cache_headers(response_headers : HTTP::Headers, last_modified : Time) : Nil | ||
response_headers["Etag"] = etag(last_modified) | ||
response_headers["Last-Modified"] = HTTP.format_time(last_modified) | ||
end | ||
|
||
private def cache_request?(context : HTTP::Server::Context, last_modified : Time) : Bool | ||
# According to RFC 7232: | ||
# A recipient must ignore If-Modified-Since if the request contains an If-None-Match header field | ||
if if_none_match = context.request.if_none_match | ||
match = {"*", context.response.headers["Etag"]} | ||
if_none_match.any? { |etag| match.includes?(etag) } | ||
elsif if_modified_since = context.request.headers["If-Modified-Since"]? | ||
header_time = HTTP.parse_time(if_modified_since) | ||
# File mtime probably has a higher resolution than the header value. | ||
# An exact comparison might be slightly off, so we add 1s padding. | ||
# Static files should generally not be modified in subsecond intervals, so this is perfectly safe. | ||
# This might be replaced by a more sophisticated time comparison when it becomes available. | ||
!!(header_time && last_modified <= header_time + 1.second) | ||
else | ||
false | ||
end | ||
end | ||
|
||
private def etag(modification_time) | ||
%{W/"#{modification_time.to_unix}"} | ||
end | ||
|
||
private def modification_time(file_path) | ||
File.info(file_path).modification_time | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters