feat(middleware): add Prism and Middleware
This commit is contained in:
1 parent
7611cf1a0e
commit
780254dfeb
2 files changed
+167
No files matched your search
@@ -0,0 +1,65 @@
|
||||
module Prism
|
||||
module Middleware
|
||||
# Serves the fingerprinted files Assets::Compiler writes to `root` back
|
||||
# out under `prefix` (default "/assets", matching what asset_path
|
||||
# generates) with a far-future, immutable Cache-Control - safe only
|
||||
# because the filename itself changes whenever the content does, so a
|
||||
# client can never be stuck holding a stale copy under the same URL.
|
||||
# A separate class from Middleware::Static (rather than a subclass)
|
||||
# because it maps a URL prefix onto a directory instead of serving a
|
||||
# directory at the root, and only fingerprinted filenames get the
|
||||
# aggressive cache header - manifest.json itself must not.
|
||||
class Assets
|
||||
FINGERPRINTED = /-([0-9a-f]{8})\.[^.]+\z/.freeze
|
||||
|
||||
def initialize(app, root:, prefix: '/assets')
|
||||
@app = app
|
||||
@root = File.expand_path(root)
|
||||
@prefix = prefix.chomp('/')
|
||||
end
|
||||
|
||||
def call(env)
|
||||
method = env['REQUEST_METHOD']
|
||||
path_info = env['PATH_INFO']
|
||||
return @app.call(env) unless %w[GET HEAD].include?(method)
|
||||
return @app.call(env) unless path_info&.start_with?("#{@prefix}/")
|
||||
|
||||
file = resolve(path_info.delete_prefix(@prefix))
|
||||
return @app.call(env) unless file
|
||||
|
||||
serve(file, head: method == 'HEAD', env: env)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve(relative)
|
||||
return nil if relative.include?("\0")
|
||||
|
||||
candidate = File.expand_path(File.join(@root, CGI.unescape(relative)))
|
||||
return nil unless candidate.start_with?("#{@root}#{File::SEPARATOR}")
|
||||
return nil unless File.file?(candidate)
|
||||
|
||||
candidate
|
||||
end
|
||||
|
||||
def serve(file, head: false, env: {})
|
||||
type = Static::ContentTypes.fetch(File.extname(file).downcase, 'application/octet-stream')
|
||||
fingerprint = FINGERPRINTED.match(File.basename(file))
|
||||
|
||||
etag, last_modified =
|
||||
if fingerprint
|
||||
# The hash is already the content digest Assets::Compiler put in
|
||||
# the filename, so we get a strong, content-derived ETag for
|
||||
# free - no need to hash the file again just to answer If-Range.
|
||||
[StaticCache.strong_etag(fingerprint[1]), File.mtime(file).utc.httpdate]
|
||||
else
|
||||
StaticCache.weak_validators(File.size(file), File.mtime(file))
|
||||
end
|
||||
|
||||
status, headers, body = Static.respond_with_range(file, type, head: head, env: env, etag: etag, last_modified: last_modified)
|
||||
headers['Cache-Control'] = 'public, max-age=31536000, immutable' if fingerprint
|
||||
[status, headers, body]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,102 @@
|
||||
require 'zlib'
|
||||
require 'stringio'
|
||||
|
||||
module Prism
|
||||
module Middleware
|
||||
# Rack::Deflater-lite: gzips response bodies for clients that send
|
||||
# `Accept-Encoding: gzip`, using stdlib Zlib only.
|
||||
#
|
||||
# A buffered (Array) body is compressed whole, in one shot, and gets an
|
||||
# exact Content-Length like any other buffered response. A
|
||||
# Prism::StreamingBody (see Response#stream) is re-wrapped in a new
|
||||
# StreamingBody that runs a *single* Zlib::Deflate stream across the
|
||||
# producer's chunks with Zlib::SYNC_FLUSH after each one - the gzip
|
||||
# equivalent of chunked transfer-encoding: the client starts receiving
|
||||
# (and can start inflating) compressed bytes as the action produces
|
||||
# them, instead of this middleware defeating streaming by buffering the
|
||||
# whole response just to gzip it in one go.
|
||||
class Compression
|
||||
# Recompressing these wastes CPU for no size win (already-compressed
|
||||
# media formats), and gzipping a response that already carries a
|
||||
# Content-Encoding (someone upstream picked one already) would
|
||||
# corrupt it rather than stack with it.
|
||||
SKIP_CONTENT_TYPES = %r{\A(?:image|audio|video)/|\Aapplication/(?:gzip|zip|x-gzip|octet-stream)}.freeze
|
||||
NO_BODY_STATUSES = [204, 205, 206, 304].freeze
|
||||
|
||||
def initialize(app, threshold: 256)
|
||||
@app = app
|
||||
@threshold = threshold
|
||||
end
|
||||
|
||||
def call(env)
|
||||
status, headers, body = @app.call(env)
|
||||
return [status, headers, body] unless candidate?(env, status, headers)
|
||||
|
||||
if body.is_a?(StreamingBody)
|
||||
[status, gzip_headers(headers), wrap_streaming(body)]
|
||||
else
|
||||
gzip_buffered(status, headers, body)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def candidate?(env, status, headers)
|
||||
return false if env['REQUEST_METHOD'] == 'HEAD'
|
||||
return false unless accepts_gzip?(env['HTTP_ACCEPT_ENCODING'])
|
||||
return false if NO_BODY_STATUSES.include?(status)
|
||||
return false if headers['Content-Encoding']
|
||||
return false if headers['Content-Range']
|
||||
return false if headers['Content-Type'].to_s =~ SKIP_CONTENT_TYPES
|
||||
true
|
||||
end
|
||||
|
||||
# Accept-Encoding: gzip;q=0.8, deflate, br;q=0
|
||||
# A coding is acceptable if named with q > 0, or covered by a `*`
|
||||
# with q > 0 and not itself named with q=0 (RFC 7231 SS5.3.4).
|
||||
def accepts_gzip?(header)
|
||||
return false if header.to_s.empty?
|
||||
codings = header.split(',').map do |part|
|
||||
name, *params = part.strip.split(';').map(&:strip)
|
||||
q = params.find { |p| p.start_with?('q=') }
|
||||
[name.downcase, q ? q.sub('q=', '').to_f : 1.0]
|
||||
end
|
||||
gzip = codings.find { |name, _| name == 'gzip' }
|
||||
return gzip[1] > 0 if gzip
|
||||
wildcard = codings.find { |name, _| name == '*' }
|
||||
wildcard ? wildcard[1] > 0 : false
|
||||
end
|
||||
|
||||
def gzip_buffered(status, headers, body)
|
||||
payload = body.join
|
||||
return [status, headers, body] if payload.bytesize < @threshold
|
||||
|
||||
compressed = Zlib.gzip(payload)
|
||||
response_headers = gzip_headers(headers)
|
||||
response_headers['Content-Length'] = compressed.bytesize.to_s
|
||||
[status, response_headers, [compressed]]
|
||||
end
|
||||
|
||||
def gzip_headers(headers)
|
||||
headers = headers.dup
|
||||
headers.delete('Content-Length')
|
||||
headers['Content-Encoding'] = 'gzip'
|
||||
existing_vary = headers['Vary']
|
||||
headers['Vary'] = existing_vary.to_s.empty? ? 'Accept-Encoding' : "#{existing_vary}, Accept-Encoding"
|
||||
headers
|
||||
end
|
||||
|
||||
def wrap_streaming(body)
|
||||
StreamingBody.new do |writer|
|
||||
deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, Zlib::MAX_WBITS + 16)
|
||||
body.each do |chunk|
|
||||
next if chunk.empty?
|
||||
writer.write(deflater.deflate(chunk, Zlib::SYNC_FLUSH))
|
||||
end
|
||||
writer.write(deflater.finish)
|
||||
deflater.close
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user