feat: extend static_cache with Prism and StaticCache

This commit is contained in:
dualfroz committed 2026-09-07 11:51:44 +02:00
1 parent b10541217b
commit a171d9d333
4 files changed
+181 -8

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
module Prism
# Conditional-GET (ETag/Last-Modified/304) and If-Range validation for
# Middleware::Static/Assets. Kept separate from Prism::ConditionalGet
# (which is mixed into Controller and reads/writes headers through
# `request`/`headers`/`halt`) because the static-file middleware works
# directly on a Rack env and a plain headers Hash, with no controller
# instance to hang state off of.
module StaticCache
# Cheap validators for an arbitrary file: a weak ETag derived from size
# and mtime (no content read - the whole point for large files) plus a
# Last-Modified date. Weak because two files can share size+mtime
# without being byte-identical, so this can only ever be used for
# If-None-Match/If-Modified-Since (which allow weak comparison), never
# for If-Range (which requires a strong validator - see range_applicable?).
def self.weak_validators(size, mtime)
etag = %(W/"#{size}-#{mtime.to_i}")
[etag, mtime.utc.httpdate]
end
# A strong ETag for a fingerprinted asset: the content hash is already
# in the filename (Assets::Compiler put it there), so unlike an
# arbitrary static file we get a real content-derived validator for
# free - no need to hash the file again just to answer If-Range.
def self.strong_etag(fingerprint_hash)
%("#{fingerprint_hash}")
end
# True when the client's cached copy is still fresh per
# If-None-Match/If-Modified-Since - caller should respond 304.
def self.not_modified?(env, etag, last_modified)
none_match = env['HTTP_IF_NONE_MATCH']
modified_since = env['HTTP_IF_MODIFIED_SINCE']
return false if none_match.nil? && modified_since.nil?
etag_fresh = none_match.nil? || etag_matches?(none_match, etag)
time_fresh = modified_since.nil? || not_newer_than?(modified_since, last_modified)
etag_fresh && time_fresh
end
# RFC 7233 SS3.2: If-Range names exactly one validator (an entity-tag or
# an HTTP-date, never both/neither/a list). When present, a Range
# request is only honored if that validator strong-matches the
# resource's *current* state; otherwise the Range header is ignored and
# the full body is sent - this is what lets a client resume a download
# safely (it re-requests the whole file the moment the file changes
# instead of stitching a byte range from the old version onto one from
# the new). Absent If-Range, Range is always honored (existing behavior).
def self.range_applicable?(env, etag, last_modified)
if_range = env['HTTP_IF_RANGE']
return true if if_range.nil?
if entity_tag?(if_range)
strong_etag_match?(if_range, etag)
else
exact_date_match?(if_range, last_modified)
end
end
def self.entity_tag?(value)
value.start_with?('"') || value.start_with?('W/"')
end
private_class_method :entity_tag?
# Strong comparison (RFC 7232 SS2.3.2): both sides must be non-weak and
# byte-identical. Our own weak validators (plain static files) can
# therefore never satisfy an If-Range by entity-tag - only a fingerprinted
# asset's strong etag can, and only when the client's tag is not itself
# marked weak.
def self.strong_etag_match?(header_value, current_etag)
return false if current_etag.nil? || current_etag.start_with?('W/')
return false if header_value.start_with?('W/')
header_value == current_etag
end
private_class_method :strong_etag_match?
def self.exact_date_match?(header_value, last_modified_header)
return false if last_modified_header.nil?
Time.httpdate(header_value) == Time.httpdate(last_modified_header)
rescue ArgumentError
false
end
private_class_method :exact_date_match?
def self.etag_matches?(header_value, current_etag)
return false if current_etag.nil?
header_value.split(',').map(&:strip).any? { |candidate| candidate == '*' || candidate == current_etag }
end
private_class_method :etag_matches?
def self.not_newer_than?(header_value, current_last_modified)
return false if current_last_modified.nil?
Time.httpdate(header_value) >= Time.httpdate(current_last_modified)
rescue ArgumentError
false
end
private_class_method :not_newer_than?
end
end
+43
View File
@@ -0,0 +1,43 @@
module Prism
# ActionController::Live-lite: a response body whose chunks are produced
# by the action itself, one at a time, instead of built up into a single
# String before headers are ever sent. Response#stream wraps the given
# block in one of these instead of the ordinary Array body, and
# Server#write_response recognizes the type (see there) to switch the
# wire format to HTTP/1.1 chunked Transfer-Encoding.
#
# Deliberately synchronous, not threaded: the block runs on the same
# thread that iterates it (#each below), in the order it calls
# `stream.write`. That is enough to let a large export write straight to
# the socket as it is generated - never holding the whole payload in
# memory at once, and letting the client start receiving bytes before the
# last row even exists - without the complexity (and crash-on-abandoned-
# connection risk) of ActionController::Live's separate writer thread.
class StreamingBody
def initialize(&producer)
@producer = producer
end
# Rack's body contract: `each` is called exactly once by whatever is
# sending the response, and yields every chunk in order. Handing the
# producer block a Writer (instead of yielding to it directly) is what
# lets action code say `stream.write(row)` repeatedly rather than
# returning an Enumerable of precomputed chunks.
def each(&sink)
@producer.call(Writer.new(sink))
nil
end
class Writer
def initialize(sink)
@sink = sink
end
def write(chunk)
@sink.call(chunk.to_s)
self
end
alias_method :<<, :write
end
end
end
+33 -8
View File
@@ -2,33 +2,40 @@ module Prism
module Template
module_function
def render(source, locals = {}, view = nil)
def render(source, locals = {}, view = nil, helpers = nil)
code = compile(source)
scope = Context.new(view).__binding
scope = Context.new(view, helpers).__binding
locals.each { |name, value| scope.local_variable_set(name.to_sym, value) }
scope.eval(code, '(prism template)')
end
def compile(source)
buffer = +"__out = +''\n"
buffer = +"@__out = +''\n"
source.split(/(<%.*?%>)/m).each do |chunk|
if chunk.start_with?('<%==')
buffer << "__out << (#{chunk[4...-2]}).to_s\n"
buffer << "@__out << (#{chunk[4...-2]}).to_s\n"
elsif chunk.start_with?('<%=')
buffer << "__out << escape_html((#{chunk[3...-2]}).to_s)\n"
buffer << "@__out << escape_html((#{chunk[3...-2]}).to_s)\n"
elsif chunk.start_with?('<%')
buffer << "#{chunk[2...-2]}\n"
else
buffer << "__out << #{chunk.inspect}\n"
buffer << "@__out << #{chunk.inspect}\n"
end
end
buffer << "__out\n"
buffer << "@__out\n"
buffer
end
# Rendering context for compiled templates. Block-taking view helpers
# (form_for, content_tag with a block, ...) are invoked from a plain
# `<% %>` tag, so they append their own markup to @__out directly
# instead of returning a string; `capture` lets them do that safely by
# temporarily swapping the buffer so nested `<%=`/`<%==` output lands
# in the captured fragment rather than the surrounding page.
class Context
def initialize(view = nil)
def initialize(view = nil, helpers = nil)
@view = view
@helpers = helpers
end
def escape_html(text)
@@ -46,6 +53,24 @@ module Prism
@view.render_collection(name, collection, locals: locals, as: as)
end
def capture
previous = @__out
@__out = +''
yield
@__out
ensure
@__out = previous
end
def method_missing(name, *args, **kwargs, &block)
return @helpers.public_send(name, *args, **kwargs, &block) if @helpers&.respond_to?(name)
super
end
def respond_to_missing?(name, include_private = false)
(@helpers && @helpers.respond_to?(name, include_private)) || super
end
def __binding
binding
end
+3
View File
@@ -0,0 +1,3 @@
require_relative 'testing/test_response'
require_relative 'testing/integration_session'
require_relative 'testing/integration_test'