diff --git a/lib/prism/static_cache.rb b/lib/prism/static_cache.rb new file mode 100644 index 0000000..81f63fd --- /dev/null +++ b/lib/prism/static_cache.rb @@ -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 diff --git a/lib/prism/streaming_body.rb b/lib/prism/streaming_body.rb new file mode 100644 index 0000000..ed2add1 --- /dev/null +++ b/lib/prism/streaming_body.rb @@ -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 diff --git a/lib/prism/template.rb b/lib/prism/template.rb index 3fcd041..02c9197 100644 --- a/lib/prism/template.rb +++ b/lib/prism/template.rb @@ -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 diff --git a/lib/prism/testing.rb b/lib/prism/testing.rb new file mode 100644 index 0000000..2bd77d6 --- /dev/null +++ b/lib/prism/testing.rb @@ -0,0 +1,3 @@ +require_relative 'testing/test_response' +require_relative 'testing/integration_session' +require_relative 'testing/integration_test'