feat: extend static_cache with Prism and StaticCache
This commit is contained in:
1 parent
b10541217b
commit
a171d9d333
4 files changed
+181
-8
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
require_relative 'testing/test_response'
|
||||
require_relative 'testing/integration_session'
|
||||
require_relative 'testing/integration_test'
|
||||
Reference in new issue
Block a user