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 Template
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def render(source, locals = {}, view = nil)
|
def render(source, locals = {}, view = nil, helpers = nil)
|
||||||
code = compile(source)
|
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) }
|
locals.each { |name, value| scope.local_variable_set(name.to_sym, value) }
|
||||||
scope.eval(code, '(prism template)')
|
scope.eval(code, '(prism template)')
|
||||||
end
|
end
|
||||||
|
|
||||||
def compile(source)
|
def compile(source)
|
||||||
buffer = +"__out = +''\n"
|
buffer = +"@__out = +''\n"
|
||||||
source.split(/(<%.*?%>)/m).each do |chunk|
|
source.split(/(<%.*?%>)/m).each do |chunk|
|
||||||
if chunk.start_with?('<%==')
|
if chunk.start_with?('<%==')
|
||||||
buffer << "__out << (#{chunk[4...-2]}).to_s\n"
|
buffer << "@__out << (#{chunk[4...-2]}).to_s\n"
|
||||||
elsif chunk.start_with?('<%=')
|
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?('<%')
|
elsif chunk.start_with?('<%')
|
||||||
buffer << "#{chunk[2...-2]}\n"
|
buffer << "#{chunk[2...-2]}\n"
|
||||||
else
|
else
|
||||||
buffer << "__out << #{chunk.inspect}\n"
|
buffer << "@__out << #{chunk.inspect}\n"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
buffer << "__out\n"
|
buffer << "@__out\n"
|
||||||
buffer
|
buffer
|
||||||
end
|
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
|
class Context
|
||||||
def initialize(view = nil)
|
def initialize(view = nil, helpers = nil)
|
||||||
@view = view
|
@view = view
|
||||||
|
@helpers = helpers
|
||||||
end
|
end
|
||||||
|
|
||||||
def escape_html(text)
|
def escape_html(text)
|
||||||
@@ -46,6 +53,24 @@ module Prism
|
|||||||
@view.render_collection(name, collection, locals: locals, as: as)
|
@view.render_collection(name, collection, locals: locals, as: as)
|
||||||
end
|
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
|
def __binding
|
||||||
binding
|
binding
|
||||||
end
|
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