Skip to content

Commit

Permalink
Implement H2 Early Hints for Rails
Browse files Browse the repository at this point in the history
When puma/puma#1403 is merged Puma will support the Early Hints status
code for sending assets before a request has finished.

While the Early Hints spec is still in draft, this PR prepares Rails to
allowing this status code.

If the proxy server supports Early Hints, it will send H2 pushes to the
client.

This PR adds a method for setting Early Hints Link headers via Rails,
and also automatically sends Early Hints if supported from the
`stylesheet_link_tag` and the `javascript_include_tag`.

Once puma supports Early Hints the `--early-hints` argument can be
passed to the server to enable this or set in the puma config with
`early_hints(true)`. Note that for Early Hints to work
in the browser the requirements are 1) a proxy that can handle H2,
and 2) HTTPS.

To start the server with Early Hints enabled pass `--early-hints` to
`rails s`.

This has been verified to work with h2o, Puma, and Rails with Chrome.

The commit adds a new option to the rails server to enable early hints
for Puma.

Early Hints spec:
https://tools.ietf.org/html/draft-ietf-httpbis-early-hints-04

[Eileen M. Uchitelle, Aaron Patterson]
  • Loading branch information
eileencodes committed Oct 4, 2017
1 parent f1e8962 commit 59a02fb
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 6 deletions.
8 changes: 8 additions & 0 deletions actionpack/CHANGELOG.md
@@ -1,3 +1,11 @@
* Add ability to enable Early Hints for HTTP/2

If supported by the server, and enabled in Puma this allows H2 Early Hints to be used.

The `javascript_include_tag` and the `stylesheet_link_tag` automatically add Early Hints if requested.

*Eileen M. Uchitelle*, *Aaron Patterson*

* Simplify cookies middleware with key rotation support * Simplify cookies middleware with key rotation support


Use the `rotate` method for both `MessageEncryptor` and Use the `rotate` method for both `MessageEncryptor` and
Expand Down
17 changes: 17 additions & 0 deletions actionpack/lib/action_dispatch/http/request.rb
Expand Up @@ -199,6 +199,23 @@ def headers
@headers ||= Http::Headers.new(self) @headers ||= Http::Headers.new(self)
end end


# Early Hints is an HTTP/2 status code that indicates hints to help a client start
# making preparations for processing the final response.
#
# If the env contains +rack.early_hints+ then the server accepts HTTP2 push for Link headers.
#
# The +send_early_hints+ method accepts an hash of links as follows:
#
# send_early_hints("Link" => "</style.css>; rel=preload; as=style\n</script.js>; rel=preload")
#
# If you are using +javascript_include_tag+ or +stylesheet_link_tag+ the
# Early Hints headers are included by default if supported.
def send_early_hints(links)
return unless env["rack.early_hints"]

env["rack.early_hints"].call(links)
end

# Returns a +String+ with the last requested path including their params. # Returns a +String+ with the last requested path including their params.
# #
# # get '/foo' # # get '/foo'
Expand Down
15 changes: 15 additions & 0 deletions actionpack/test/dispatch/request_test.rb
Expand Up @@ -1304,3 +1304,18 @@ class RequestFormData < BaseRequestTest
assert !request.form_data? assert !request.form_data?
end end
end end

class EarlyHintsRequestTest < BaseRequestTest
def setup
super
@env["rack.early_hints"] = lambda { |links| links }
@request = stub_request
end

test "when early hints is set in the env link headers are sent" do
early_hints = @request.send_early_hints("Link" => "</style.css>; rel=preload; as=style\n</script.js>; rel=preload")
expected_hints = { "Link" => "</style.css>; rel=preload; as=style\n</script.js>; rel=preload" }

assert_equal expected_hints, early_hints
end
end
30 changes: 26 additions & 4 deletions actionview/lib/action_view/helpers/asset_tag_helper.rb
Expand Up @@ -37,6 +37,9 @@ module AssetTagHelper
# When the Asset Pipeline is enabled, you can pass the name of your manifest as # When the Asset Pipeline is enabled, you can pass the name of your manifest as
# source, and include other JavaScript or CoffeeScript files inside the manifest. # source, and include other JavaScript or CoffeeScript files inside the manifest.
# #
# If the server supports Early Hints header links for these assets will be
# automatically pushed.
#
# ==== Options # ==== Options
# #
# When the last parameter is a hash you can add HTML attributes using that # When the last parameter is a hash you can add HTML attributes using that
Expand Down Expand Up @@ -77,12 +80,20 @@ module AssetTagHelper
def javascript_include_tag(*sources) def javascript_include_tag(*sources)
options = sources.extract_options!.stringify_keys options = sources.extract_options!.stringify_keys
path_options = options.extract!("protocol", "extname", "host", "skip_pipeline").symbolize_keys path_options = options.extract!("protocol", "extname", "host", "skip_pipeline").symbolize_keys
sources.uniq.map { |source| early_hints_links = []

sources_tags = sources.uniq.map { |source|
href = path_to_javascript(source, path_options)
early_hints_links << "<#{href}>; rel=preload; as=script"
tag_options = { tag_options = {
"src" => path_to_javascript(source, path_options) "src" => href
}.merge!(options) }.merge!(options)
content_tag("script".freeze, "", tag_options) content_tag("script".freeze, "", tag_options)
}.join("\n").html_safe }.join("\n").html_safe

request.send_early_hints("Link" => early_hints_links.join("\n"))

sources_tags
end end


# Returns a stylesheet link tag for the sources specified as arguments. If # Returns a stylesheet link tag for the sources specified as arguments. If
Expand All @@ -92,6 +103,9 @@ def javascript_include_tag(*sources)
# to "screen", so you must explicitly set it to "all" for the stylesheet(s) to # to "screen", so you must explicitly set it to "all" for the stylesheet(s) to
# apply to all media types. # apply to all media types.
# #
# If the server supports Early Hints header links for these assets will be
# automatically pushed.
#
# stylesheet_link_tag "style" # stylesheet_link_tag "style"
# # => <link href="/assets/style.css" media="screen" rel="stylesheet" /> # # => <link href="/assets/style.css" media="screen" rel="stylesheet" />
# #
Expand All @@ -113,14 +127,22 @@ def javascript_include_tag(*sources)
def stylesheet_link_tag(*sources) def stylesheet_link_tag(*sources)
options = sources.extract_options!.stringify_keys options = sources.extract_options!.stringify_keys
path_options = options.extract!("protocol", "host", "skip_pipeline").symbolize_keys path_options = options.extract!("protocol", "host", "skip_pipeline").symbolize_keys
sources.uniq.map { |source| early_hints_links = []

sources_tags = sources.uniq.map { |source|
href = path_to_stylesheet(source, path_options)
early_hints_links << "<#{href}>; rel=preload; as=stylesheet"
tag_options = { tag_options = {
"rel" => "stylesheet", "rel" => "stylesheet",
"media" => "screen", "media" => "screen",
"href" => path_to_stylesheet(source, path_options) "href" => href
}.merge!(options) }.merge!(options)
tag(:link, tag_options) tag(:link, tag_options)
}.join("\n").html_safe }.join("\n").html_safe

request.send_early_hints("Link" => early_hints_links.join("\n"))

sources_tags
end end


# Returns a link tag that browsers and feed readers can use to auto-detect # Returns a link tag that browsers and feed readers can use to auto-detect
Expand Down
5 changes: 4 additions & 1 deletion actionview/test/template/asset_tag_helper_test.rb
Expand Up @@ -19,6 +19,7 @@ def protocol() "http://" end
def ssl?() false end def ssl?() false end
def host_with_port() "localhost" end def host_with_port() "localhost" end
def base_url() "http://www.example.com" end def base_url() "http://www.example.com" end
def send_early_hints(links) end
end.new end.new


@controller.request = @request @controller.request = @request
Expand Down Expand Up @@ -653,7 +654,9 @@ def setup
@controller = BasicController.new @controller = BasicController.new
@controller.config.relative_url_root = "/collaboration/hieraki" @controller.config.relative_url_root = "/collaboration/hieraki"


@request = Struct.new(:protocol, :base_url).new("gopher://", "gopher://www.example.com") @request = Struct.new(:protocol, :base_url) do
def send_early_hints(links); end
end.new("gopher://", "gopher://www.example.com")
@controller.request = @request @controller.request = @request
end end


Expand Down
4 changes: 4 additions & 0 deletions actionview/test/template/javascript_helper_test.rb
Expand Up @@ -6,11 +6,15 @@ class JavaScriptHelperTest < ActionView::TestCase
tests ActionView::Helpers::JavaScriptHelper tests ActionView::Helpers::JavaScriptHelper


attr_accessor :output_buffer attr_accessor :output_buffer
attr_reader :request


setup do setup do
@old_escape_html_entities_in_json = ActiveSupport.escape_html_entities_in_json @old_escape_html_entities_in_json = ActiveSupport.escape_html_entities_in_json
ActiveSupport.escape_html_entities_in_json = true ActiveSupport.escape_html_entities_in_json = true
@template = self @template = self
@request = Class.new do
def send_early_hints(links) end
end.new
end end


def teardown def teardown
Expand Down
8 changes: 7 additions & 1 deletion railties/lib/rails/commands/server/server_command.rb
Expand Up @@ -127,6 +127,7 @@ class ServerCommand < Base # :nodoc:
class_option "dev-caching", aliases: "-C", type: :boolean, default: nil, class_option "dev-caching", aliases: "-C", type: :boolean, default: nil,
desc: "Specifies whether to perform caching in development." desc: "Specifies whether to perform caching in development."
class_option "restart", type: :boolean, default: nil, hide: true class_option "restart", type: :boolean, default: nil, hide: true
class_option "early_hints", type: :boolean, default: nil, desc: "Enables HTTP/2 early hints."


def initialize(args = [], local_options = {}, config = {}) def initialize(args = [], local_options = {}, config = {})
@original_options = local_options @original_options = local_options
Expand Down Expand Up @@ -161,7 +162,8 @@ def server_options
daemonize: options[:daemon], daemonize: options[:daemon],
pid: pid, pid: pid,
caching: options["dev-caching"], caching: options["dev-caching"],
restart_cmd: restart_command restart_cmd: restart_command,
early_hints: early_hints
} }
end end
end end
Expand Down Expand Up @@ -227,6 +229,10 @@ def restart_command
"bin/rails server #{@server} #{@original_options.join(" ")} --restart" "bin/rails server #{@server} #{@original_options.join(" ")} --restart"
end end


def early_hints
options[:early_hints]
end

def pid def pid
File.expand_path(options[:pid]) File.expand_path(options[:pid])
end end
Expand Down
12 changes: 12 additions & 0 deletions railties/test/commands/server_test.rb
Expand Up @@ -81,6 +81,18 @@ def test_caching_with_option
assert_equal false, options[:caching] assert_equal false, options[:caching]
end end


def test_early_hints_with_option
args = ["--early-hints"]
options = parse_arguments(args)
assert_equal true, options[:early_hints]
end

def test_early_hints_is_nil_by_default
args = []
options = parse_arguments(args)
assert_nil options[:early_hints]
end

def test_log_stdout def test_log_stdout
with_rack_env nil do with_rack_env nil do
with_rails_env nil do with_rails_env nil do
Expand Down

0 comments on commit 59a02fb

Please sign in to comment.