From 780254dfeb67a509471cbb7c6045e0e03687371b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 17:41:54 +0200 Subject: [PATCH] feat(middleware): add Prism and Middleware --- lib/prism/middleware/assets.rb | 65 ++++++++++++++++++ lib/prism/middleware/compression.rb | 102 ++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 lib/prism/middleware/assets.rb create mode 100644 lib/prism/middleware/compression.rb diff --git a/lib/prism/middleware/assets.rb b/lib/prism/middleware/assets.rb new file mode 100644 index 0000000..1c61e04 --- /dev/null +++ b/lib/prism/middleware/assets.rb @@ -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 diff --git a/lib/prism/middleware/compression.rb b/lib/prism/middleware/compression.rb new file mode 100644 index 0000000..dab065d --- /dev/null +++ b/lib/prism/middleware/compression.rb @@ -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