From 59a02fb7bcbe68f26e1e7fdcec45c00c66e4a065 Mon Sep 17 00:00:00 2001 From: eileencodes Date: Tue, 26 Sep 2017 13:27:53 -0400 Subject: [PATCH] Implement H2 Early Hints for Rails 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] --- actionpack/CHANGELOG.md | 8 +++++ .../lib/action_dispatch/http/request.rb | 17 +++++++++++ actionpack/test/dispatch/request_test.rb | 15 ++++++++++ .../action_view/helpers/asset_tag_helper.rb | 30 ++++++++++++++++--- .../test/template/asset_tag_helper_test.rb | 5 +++- .../test/template/javascript_helper_test.rb | 4 +++ .../rails/commands/server/server_command.rb | 8 ++++- railties/test/commands/server_test.rb | 12 ++++++++ 8 files changed, 93 insertions(+), 6 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 16090e794654e..adb86aad9f54b 100644 --- a/actionpack/CHANGELOG.md +++ b/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 Use the `rotate` method for both `MessageEncryptor` and diff --git a/actionpack/lib/action_dispatch/http/request.rb b/actionpack/lib/action_dispatch/http/request.rb index dee7be184aec8..5c172aecad576 100644 --- a/actionpack/lib/action_dispatch/http/request.rb +++ b/actionpack/lib/action_dispatch/http/request.rb @@ -199,6 +199,23 @@ def headers @headers ||= Http::Headers.new(self) 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" => "; rel=preload; as=style\n; 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. # # # get '/foo' diff --git a/actionpack/test/dispatch/request_test.rb b/actionpack/test/dispatch/request_test.rb index 68c6d26364410..2a18395aacf03 100644 --- a/actionpack/test/dispatch/request_test.rb +++ b/actionpack/test/dispatch/request_test.rb @@ -1304,3 +1304,18 @@ class RequestFormData < BaseRequestTest assert !request.form_data? 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" => "; rel=preload; as=style\n; rel=preload") + expected_hints = { "Link" => "; rel=preload; as=style\n; rel=preload" } + + assert_equal expected_hints, early_hints + end +end diff --git a/actionview/lib/action_view/helpers/asset_tag_helper.rb b/actionview/lib/action_view/helpers/asset_tag_helper.rb index bc2713d13e898..f4f1030ac41bb 100644 --- a/actionview/lib/action_view/helpers/asset_tag_helper.rb +++ b/actionview/lib/action_view/helpers/asset_tag_helper.rb @@ -37,6 +37,9 @@ module AssetTagHelper # 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. # + # If the server supports Early Hints header links for these assets will be + # automatically pushed. + # # ==== Options # # When the last parameter is a hash you can add HTML attributes using that @@ -77,12 +80,20 @@ module AssetTagHelper def javascript_include_tag(*sources) options = sources.extract_options!.stringify_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 = { - "src" => path_to_javascript(source, path_options) + "src" => href }.merge!(options) content_tag("script".freeze, "", tag_options) }.join("\n").html_safe + + request.send_early_hints("Link" => early_hints_links.join("\n")) + + sources_tags end # Returns a stylesheet link tag for the sources specified as arguments. If @@ -92,6 +103,9 @@ def javascript_include_tag(*sources) # to "screen", so you must explicitly set it to "all" for the stylesheet(s) to # apply to all media types. # + # If the server supports Early Hints header links for these assets will be + # automatically pushed. + # # stylesheet_link_tag "style" # # => # @@ -113,14 +127,22 @@ def javascript_include_tag(*sources) def stylesheet_link_tag(*sources) options = sources.extract_options!.stringify_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 = { "rel" => "stylesheet", "media" => "screen", - "href" => path_to_stylesheet(source, path_options) + "href" => href }.merge!(options) tag(:link, tag_options) }.join("\n").html_safe + + request.send_early_hints("Link" => early_hints_links.join("\n")) + + sources_tags end # Returns a link tag that browsers and feed readers can use to auto-detect diff --git a/actionview/test/template/asset_tag_helper_test.rb b/actionview/test/template/asset_tag_helper_test.rb index 182d8f89f7aeb..7475f5cc3cef7 100644 --- a/actionview/test/template/asset_tag_helper_test.rb +++ b/actionview/test/template/asset_tag_helper_test.rb @@ -19,6 +19,7 @@ def protocol() "http://" end def ssl?() false end def host_with_port() "localhost" end def base_url() "http://www.example.com" end + def send_early_hints(links) end end.new @controller.request = @request @@ -653,7 +654,9 @@ def setup @controller = BasicController.new @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 end diff --git a/actionview/test/template/javascript_helper_test.rb b/actionview/test/template/javascript_helper_test.rb index 4478c9f4abfe8..a72bc6c2fe518 100644 --- a/actionview/test/template/javascript_helper_test.rb +++ b/actionview/test/template/javascript_helper_test.rb @@ -6,11 +6,15 @@ class JavaScriptHelperTest < ActionView::TestCase tests ActionView::Helpers::JavaScriptHelper attr_accessor :output_buffer + attr_reader :request setup do @old_escape_html_entities_in_json = ActiveSupport.escape_html_entities_in_json ActiveSupport.escape_html_entities_in_json = true @template = self + @request = Class.new do + def send_early_hints(links) end + end.new end def teardown diff --git a/railties/lib/rails/commands/server/server_command.rb b/railties/lib/rails/commands/server/server_command.rb index 785265d766f36..5b5037d3deb53 100644 --- a/railties/lib/rails/commands/server/server_command.rb +++ b/railties/lib/rails/commands/server/server_command.rb @@ -127,6 +127,7 @@ class ServerCommand < Base # :nodoc: class_option "dev-caching", aliases: "-C", type: :boolean, default: nil, desc: "Specifies whether to perform caching in development." 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 = {}) @original_options = local_options @@ -161,7 +162,8 @@ def server_options daemonize: options[:daemon], pid: pid, caching: options["dev-caching"], - restart_cmd: restart_command + restart_cmd: restart_command, + early_hints: early_hints } end end @@ -227,6 +229,10 @@ def restart_command "bin/rails server #{@server} #{@original_options.join(" ")} --restart" end + def early_hints + options[:early_hints] + end + def pid File.expand_path(options[:pid]) end diff --git a/railties/test/commands/server_test.rb b/railties/test/commands/server_test.rb index 556c2289e789c..a6201e4f04e4f 100644 --- a/railties/test/commands/server_test.rb +++ b/railties/test/commands/server_test.rb @@ -81,6 +81,18 @@ def test_caching_with_option assert_equal false, options[:caching] 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 with_rack_env nil do with_rails_env nil do