async-http 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea4e711204ad0a9af1e1e635a4ce7248683ae570e783bd8648e9d8e9c8c21b9c
4
- data.tar.gz: 14dd2e09b46342b06aebf142f40d8c75aa864cee62df950452ef07e47ec2e7e0
3
+ metadata.gz: 605cb4a26c37dbfdaa3225fb306b3c2bc9e01da814094babb8e46cdb05594999
4
+ data.tar.gz: 40c28864c03492bc20d8aa02947210841ca5f2c75b841e222c90d8f3a36102af
5
5
  SHA512:
6
- metadata.gz: 7b667f21701e3e9f6d77f7af842b2540c979ebefe9f94a07fda2eb617e5e1327621d51fc7c0caf6e97245228db09d42a2850f2a96c94c3fbc62683837c4995cc
7
- data.tar.gz: 256a13cfc5fd2f3cef207086902893d4ec5753d44af7ae0422c12636cb756b7ca27fe498d2810fe3757d84e5470157900c8d9be51a714e457a0f1f18342dd193
6
+ metadata.gz: 95eb6bb5d079d59b9ed9698d648bd709a7f60ae1a5833547cadd9a11def31e6e8d99b929650df676c0ed215a0719bd604ffc7c57c6e047cdefaa0ac11ff2d71b
7
+ data.tar.gz: 0cdbfe53cbcf3acb1529536ddac851c09e53dd9b7c40fb1d1059abc073138f67cda426a5825966052d52a2d35f39170047c814f7ca6168b25f149fb2baf1e7e0
checksums.yaml.gz.sig CHANGED
Binary file
@@ -30,7 +30,7 @@ def server
30
30
 
31
31
  container = Async::Container.new
32
32
 
33
- Console.logger.info(self){"Starting server..."}
33
+ Console.info(self){"Starting server..."}
34
34
 
35
35
  container.run(count: 1) do
36
36
  server = Async::HTTP::Server.for(endpoint, protocol: Async::HTTP::Protocol::HTTP2, scheme: "https") do |request|
@@ -81,7 +81,7 @@ module Async
81
81
 
82
82
  def close
83
83
  while @pool.busy?
84
- Console.logger.warn(self) {"Waiting for #{@protocol} pool to drain: #{@pool}"}
84
+ Console.warn(self) {"Waiting for #{@protocol} pool to drain: #{@pool}"}
85
85
  @pool.wait
86
86
  end
87
87
 
@@ -164,7 +164,7 @@ module Async
164
164
  self.assign_default_tags(options[:tags] ||= {})
165
165
 
166
166
  Async::Pool::Controller.wrap(**options) do
167
- Console.logger.debug(self) {"Making connection to #{@endpoint.inspect}"}
167
+ Console.debug(self) {"Making connection to #{@endpoint.inspect}"}
168
168
 
169
169
  @protocol.client(@endpoint.connect)
170
170
  end
@@ -40,7 +40,7 @@ module Async
40
40
  #
41
41
  # @parameter scheme [String] The scheme to use, e.g. "http" or "https".
42
42
  # @parameter hostname [String] The hostname to connect to (or bind to).
43
- # @parameter *options [Hash] Additional options, passed to {#initialize}.
43
+ # @parameter *options [Hash] Additional options, passed to {initialize}.
44
44
  def self.for(scheme, hostname, path = "/", **options)
45
45
  # TODO: Consider using URI.for once it becomes available:
46
46
  uri_klass = SCHEMES.fetch(scheme.downcase) do
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ module Async
7
+ module HTTP
8
+ module Protocol
9
+ class Configured
10
+ def initialize(protocol, **options)
11
+ @protocol = protocol
12
+ @options = options
13
+ end
14
+
15
+ # @attribute [Protocol] The underlying protocol.
16
+ attr :protocol
17
+
18
+ # @attribute [Hash] The options to pass to the protocol.
19
+ attr :options
20
+
21
+ def client(peer, **options)
22
+ options = @options.merge(options)
23
+ @protocol.client(peer, **options)
24
+ end
25
+
26
+ def server(peer, **options)
27
+ options = @options.merge(options)
28
+ @protocol.server(peer, **options)
29
+ end
30
+
31
+ def names
32
+ @protocol.names
33
+ end
34
+ end
35
+
36
+ module Configurable
37
+ def new(**options)
38
+ Configured.new(self, **options)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ module Async
7
+ module HTTP
8
+ module Protocol
9
+ # This module provides a default instance of the protocol, which can be used to create clients and servers. The name is a play on "Default" + "Singleton".
10
+ module Defaulton
11
+ def self.extended(base)
12
+ base.instance_variable_set(:@default, base.new)
13
+ end
14
+
15
+ attr_accessor :default
16
+
17
+ # Create a client for an outbound connection, using the default instance.
18
+ def client(peer, **options)
19
+ default.client(peer, **options)
20
+ end
21
+
22
+ # Create a server for an inbound connection, using the default instance.
23
+ def server(peer, **options)
24
+ default.server(peer, **options)
25
+ end
26
+
27
+ # @returns [Array] The names of the supported protocol, used for Application Layer Protocol Negotiation (ALPN), using the default instance.
28
+ def names
29
+ default.names
30
+ end
31
+ end
32
+
33
+ private_constant :Defaulton
34
+ end
35
+ end
36
+ end
@@ -4,19 +4,33 @@
4
4
  # Copyright, 2024, by Thomas Morgan.
5
5
  # Copyright, 2024, by Samuel Williams.
6
6
 
7
+ require_relative "defaulton"
8
+
7
9
  require_relative "http1"
8
10
  require_relative "http2"
9
11
 
10
12
  module Async
11
13
  module HTTP
12
14
  module Protocol
13
- # HTTP is an http:// server that auto-selects HTTP/1.1 or HTTP/2 by detecting the HTTP/2
14
- # connection preface.
15
- module HTTP
15
+ # HTTP is an http:// server that auto-selects HTTP/1.1 or HTTP/2 by detecting the HTTP/2 connection preface.
16
+ class HTTP
16
17
  HTTP2_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
17
18
  HTTP2_PREFACE_SIZE = HTTP2_PREFACE.bytesize
18
19
 
19
- def self.protocol_for(stream)
20
+ # Create a new HTTP protocol instance.
21
+ #
22
+ # @parameter http1 [HTTP1] The HTTP/1 protocol instance.
23
+ # @parameter http2 [HTTP2] The HTTP/2 protocol instance.
24
+ def initialize(http1: HTTP1, http2: HTTP2)
25
+ @http1 = http1
26
+ @http2 = http2
27
+ end
28
+
29
+ # Determine if the inbound connection is HTTP/1 or HTTP/2.
30
+ #
31
+ # @parameter stream [IO::Stream] The stream to detect the protocol for.
32
+ # @returns [Class] The protocol class to use.
33
+ def protocol_for(stream)
20
34
  # Detect HTTP/2 connection preface
21
35
  # https://www.rfc-editor.org/rfc/rfc9113.html#section-3.4
22
36
  preface = stream.peek do |read_buffer|
@@ -29,27 +43,35 @@ module Async
29
43
  end
30
44
 
31
45
  if preface == HTTP2_PREFACE
32
- HTTP2
46
+ @http2
33
47
  else
34
- HTTP1
48
+ @http1
35
49
  end
36
50
  end
37
51
 
38
- # Only inbound connections can detect HTTP1 vs HTTP2 for http://.
39
- # Outbound connections default to HTTP1.
40
- def self.client(peer, **options)
41
- HTTP1.client(peer, **options)
52
+ # Create a client for an outbound connection. Defaults to HTTP/1 for plaintext connections.
53
+ #
54
+ # @parameter peer [IO] The peer to communicate with.
55
+ # @parameter options [Hash] Options to pass to the protocol, keyed by protocol class.
56
+ def client(peer, **options)
57
+ options = options[@http1] || {}
58
+
59
+ return @http1.client(peer, **options)
42
60
  end
43
61
 
44
- def self.server(peer, **options)
45
- stream = ::IO::Stream(peer)
62
+ # Create a server for an inbound connection. Able to detect HTTP1 and HTTP2.
63
+ #
64
+ # @parameter peer [IO] The peer to communicate with.
65
+ # @parameter options [Hash] Options to pass to the protocol, keyed by protocol class.
66
+ def server(peer, **options)
67
+ stream = IO::Stream(peer)
68
+ protocol = protocol_for(stream)
69
+ options = options[protocol] || {}
46
70
 
47
- return protocol_for(stream).server(stream, **options)
71
+ return protocol.server(stream, **options)
48
72
  end
49
73
 
50
- def self.names
51
- ["h2", "http/1.1", "http/1.0"]
52
- end
74
+ extend Defaulton
53
75
  end
54
76
  end
55
77
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "connection"
7
7
 
@@ -32,14 +32,21 @@ module Async
32
32
 
33
33
  # Used by the client to send requests to the remote server.
34
34
  def call(request, task: Task.current)
35
- Console.logger.debug(self) {"#{request.method} #{request.path} #{request.headers.inspect}"}
36
-
37
35
  # Mark the start of the trailers:
38
36
  trailer = request.headers.trailer!
39
37
 
40
38
  # We carefully interpret https://tools.ietf.org/html/rfc7230#section-6.3.1 to implement this correctly.
41
39
  begin
42
- write_request(request.authority, request.method, request.path, @version, request.headers)
40
+ target = request.path
41
+ authority = request.authority
42
+
43
+ # If we are using a CONNECT request, we need to use the authority as the target:
44
+ if request.connect?
45
+ target = authority
46
+ authority = nil
47
+ end
48
+
49
+ write_request(authority, request.method, target, @version, request.headers)
43
50
  rescue
44
51
  # If we fail to fully write the request and body, we can retry this request.
45
52
  raise RequestFailed
@@ -14,9 +14,10 @@ module Async
14
14
  module Protocol
15
15
  module HTTP1
16
16
  class Connection < ::Protocol::HTTP1::Connection
17
- def initialize(stream, version)
18
- super(stream)
17
+ def initialize(stream, version, **options)
18
+ super(stream, **options)
19
19
 
20
+ # On the client side, we need to send the HTTP version with the initial request. On the server side, there are some scenarios (bad request) where we don't know the request version. In those cases, we use this value, which is either hard coded based on the protocol being used, OR could be negotiated during the connection setup (e.g. ALPN).
20
21
  @version = version
21
22
  end
22
23
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "../request"
7
7
 
@@ -10,21 +10,45 @@ module Async
10
10
  module Protocol
11
11
  module HTTP1
12
12
  class Request < Protocol::Request
13
+ def self.valid_path?(target)
14
+ if target.start_with?("/")
15
+ return true
16
+ elsif target == "*"
17
+ return true
18
+ else
19
+ return false
20
+ end
21
+ end
22
+
23
+ URI_PATTERN = %r{\A(?<scheme>[^:/]+)://(?<authority>[^/]+)(?<path>.*)\z}
24
+
13
25
  def self.read(connection)
14
- if parts = connection.read_request
15
- self.new(connection, *parts)
26
+ connection.read_request do |authority, method, target, version, headers, body|
27
+ if method == ::Protocol::HTTP::Methods::CONNECT
28
+ # We put the target into the authority field for CONNECT requests, as per HTTP/2 semantics.
29
+ self.new(connection, nil, target, method, nil, version, headers, body)
30
+ elsif valid_path?(target)
31
+ # This is a valid request.
32
+ self.new(connection, nil, authority, method, target, version, headers, body)
33
+ elsif match = target.match(URI_PATTERN)
34
+ # We map the incoming absolute URI target to the scheme, authority, and path fields of the request.
35
+ self.new(connection, match[:scheme], match[:authority], method, match[:path], version, headers, body)
36
+ else
37
+ # This is an invalid request.
38
+ raise ::Protocol::HTTP1::BadRequest.new("Invalid request target: #{target}")
39
+ end
16
40
  end
17
41
  end
18
42
 
19
43
  UPGRADE = "upgrade"
20
44
 
21
- def initialize(connection, authority, method, path, version, headers, body)
45
+ def initialize(connection, scheme, authority, method, path, version, headers, body)
22
46
  @connection = connection
23
47
 
24
48
  # HTTP/1 requests with an upgrade header (which can contain zero or more values) are extracted into the protocol field of the request, and we expect a response to select one of those protocols with a status code of 101 Switching Protocols.
25
49
  protocol = headers.delete("upgrade")
26
50
 
27
- super(nil, authority, method, path, version, headers, body, protocol, self.public_method(:write_interim_response))
51
+ super(scheme, authority, method, path, version, headers, body, protocol, self.public_method(:write_interim_response))
28
52
  end
29
53
 
30
54
  def connection
@@ -34,7 +34,7 @@ module Async
34
34
  write_body(@version, nil)
35
35
  rescue => error
36
36
  # At this point, there is very little we can do to recover:
37
- Console::Event::Failure.for(error).emit(self, "Failed to write failure response!", severity: :debug)
37
+ Console.debug(self, "Failed to write failure response!", error)
38
38
  end
39
39
 
40
40
  def next_request
@@ -4,6 +4,8 @@
4
4
  # Copyright, 2017-2024, by Samuel Williams.
5
5
  # Copyright, 2024, by Thomas Morgan.
6
6
 
7
+ require_relative "configurable"
8
+
7
9
  require_relative "http1/client"
8
10
  require_relative "http1/server"
9
11
 
@@ -13,28 +15,41 @@ module Async
13
15
  module HTTP
14
16
  module Protocol
15
17
  module HTTP1
18
+ extend Configurable
19
+
16
20
  VERSION = "HTTP/1.1"
17
21
 
22
+ # @returns [Boolean] Whether the protocol supports bidirectional communication.
18
23
  def self.bidirectional?
19
24
  true
20
25
  end
21
26
 
27
+ # @returns [Boolean] Whether the protocol supports trailers.
22
28
  def self.trailer?
23
29
  true
24
30
  end
25
31
 
26
- def self.client(peer)
32
+ # Create a client for an outbound connection.
33
+ #
34
+ # @parameter peer [IO] The peer to communicate with.
35
+ # @parameter options [Hash] Options to pass to the client instance.
36
+ def self.client(peer, **options)
27
37
  stream = ::IO::Stream(peer)
28
38
 
29
- return HTTP1::Client.new(stream, VERSION)
39
+ return HTTP1::Client.new(stream, VERSION, **options)
30
40
  end
31
41
 
32
- def self.server(peer)
42
+ # Create a server for an inbound connection.
43
+ #
44
+ # @parameter peer [IO] The peer to communicate with.
45
+ # @parameter options [Hash] Options to pass to the server instance.
46
+ def self.server(peer, **options)
33
47
  stream = ::IO::Stream(peer)
34
48
 
35
- return HTTP1::Server.new(stream, VERSION)
49
+ return HTTP1::Server.new(stream, VERSION, **options)
36
50
  end
37
51
 
52
+ # @returns [Array] The names of the supported protocol.
38
53
  def self.names
39
54
  ["http/1.1", "http/1.0"]
40
55
  end
@@ -10,28 +10,41 @@ module Async
10
10
  module HTTP
11
11
  module Protocol
12
12
  module HTTP10
13
+ extend Configurable
14
+
13
15
  VERSION = "HTTP/1.0"
14
16
 
17
+ # @returns [Boolean] Whether the protocol supports bidirectional communication.
15
18
  def self.bidirectional?
16
19
  false
17
20
  end
18
21
 
22
+ # @returns [Boolean] Whether the protocol supports trailers.
19
23
  def self.trailer?
20
24
  false
21
25
  end
22
26
 
23
- def self.client(peer)
27
+ # Create a client for an outbound connection.
28
+ #
29
+ # @parameter peer [IO] The peer to communicate with.
30
+ # @parameter options [Hash] Options to pass to the client instance.
31
+ def self.client(peer, **options)
24
32
  stream = ::IO::Stream(peer)
25
33
 
26
- return HTTP1::Client.new(stream, VERSION)
34
+ return HTTP1::Client.new(stream, VERSION, **options)
27
35
  end
28
36
 
29
- def self.server(peer)
37
+ # Create a server for an inbound connection.
38
+ #
39
+ # @parameter peer [IO] The peer to communicate with.
40
+ # @parameter options [Hash] Options to pass to the server instance.
41
+ def self.server(peer, **options)
30
42
  stream = ::IO::Stream(peer)
31
43
 
32
- return HTTP1::Server.new(stream, VERSION)
44
+ return HTTP1::Server.new(stream, VERSION, **options)
33
45
  end
34
46
 
47
+ # @returns [Array] The names of the supported protocol.
35
48
  def self.names
36
49
  ["http/1.0"]
37
50
  end
@@ -11,28 +11,41 @@ module Async
11
11
  module HTTP
12
12
  module Protocol
13
13
  module HTTP11
14
+ extend Configurable
15
+
14
16
  VERSION = "HTTP/1.1"
15
17
 
18
+ # @returns [Boolean] Whether the protocol supports bidirectional communication.
16
19
  def self.bidirectional?
17
20
  true
18
21
  end
19
22
 
23
+ # @returns [Boolean] Whether the protocol supports trailers.
20
24
  def self.trailer?
21
25
  true
22
26
  end
23
27
 
24
- def self.client(peer)
28
+ # Create a client for an outbound connection.
29
+ #
30
+ # @parameter peer [IO] The peer to communicate with.
31
+ # @parameter options [Hash] Options to pass to the client instance.
32
+ def self.client(peer, **options)
25
33
  stream = ::IO::Stream(peer)
26
34
 
27
- return HTTP1::Client.new(stream, VERSION)
35
+ return HTTP1::Client.new(stream, VERSION, **options)
28
36
  end
29
37
 
30
- def self.server(peer)
38
+ # Create a server for an inbound connection.
39
+ #
40
+ # @parameter peer [IO] The peer to communicate with.
41
+ # @parameter options [Hash] Options to pass to the server instance.
42
+ def self.server(peer, **options)
31
43
  stream = ::IO::Stream(peer)
32
44
 
33
- return HTTP1::Server.new(stream, VERSION)
45
+ return HTTP1::Server.new(stream, VERSION, **options)
34
46
  end
35
47
 
48
+ # @returns [Array] The names of the supported protocol.
36
49
  def self.names
37
50
  ["http/1.1"]
38
51
  end
@@ -32,8 +32,6 @@ module Async
32
32
  def call(request)
33
33
  raise ::Protocol::HTTP2::Error, "Connection closed!" if self.closed?
34
34
 
35
- @count += 1
36
-
37
35
  response = create_response
38
36
  write_request(response, request)
39
37
  read_response(response)
@@ -26,10 +26,9 @@ module Async
26
26
  TRAILER = "trailer".freeze
27
27
 
28
28
  module Connection
29
- def initialize(*)
29
+ def initialize(...)
30
30
  super
31
31
 
32
- @count = 0
33
32
  @reader = nil
34
33
 
35
34
  # Writing multiple frames at the same time can cause odd problems if frames are only partially written. So we use a semaphore to ensure frames are written in their entirety.
@@ -41,7 +40,7 @@ module Async
41
40
  end
42
41
 
43
42
  def to_s
44
- "\#<#{self.class} #{@count} requests, #{@streams.count} active streams>"
43
+ "\#<#{self.class} #{@streams.count} active streams>"
45
44
  end
46
45
 
47
46
  def as_json(...)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "../request"
7
7
  require_relative "stream"
@@ -99,7 +99,7 @@ module Async
99
99
  end
100
100
 
101
101
  def valid?
102
- @scheme and @method and @path
102
+ @scheme and @method and (@path or @method == ::Protocol::HTTP::Methods::CONNECT)
103
103
  end
104
104
 
105
105
  def hijack?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "../response"
7
7
  require_relative "stream"
@@ -204,9 +204,12 @@ module Async
204
204
  pseudo_headers = [
205
205
  [SCHEME, request.scheme],
206
206
  [METHOD, request.method],
207
- [PATH, request.path],
208
207
  ]
209
208
 
209
+ if path = request.path
210
+ pseudo_headers << [PATH, path]
211
+ end
212
+
210
213
  # To ensure that the HTTP/1.1 request line can be reproduced accurately, this pseudo-header field MUST be omitted when translating from an HTTP/1.1 request that has a request target in origin or asterisk form (see [RFC7230], Section 5.3). Clients that generate HTTP/2 requests directly SHOULD use the :authority pseudo-header field instead of the Host header field.
211
214
  if authority = request.authority
212
215
  pseudo_headers << [AUTHORITY, authority]
@@ -51,8 +51,6 @@ module Async
51
51
  @requests&.async do |task, request|
52
52
  task.annotate("Incoming request: #{request.method} #{request.path.inspect}.")
53
53
 
54
- @count += 1
55
-
56
54
  task.defer_stop do
57
55
  response = yield(request)
58
56
  rescue
@@ -65,7 +65,7 @@ module Async
65
65
  @input.close_write
66
66
  end
67
67
  rescue ::Protocol::HTTP2::HeaderError => error
68
- Console.logger.debug(self, error)
68
+ Console.debug(self, "Error while processing headers!", error)
69
69
 
70
70
  send_reset_stream(error.code)
71
71
  end
@@ -4,6 +4,8 @@
4
4
  # Copyright, 2018-2024, by Samuel Williams.
5
5
  # Copyright, 2024, by Thomas Morgan.
6
6
 
7
+ require_relative "configurable"
8
+
7
9
  require_relative "http2/client"
8
10
  require_relative "http2/server"
9
11
 
@@ -13,16 +15,21 @@ module Async
13
15
  module HTTP
14
16
  module Protocol
15
17
  module HTTP2
18
+ extend Configurable
19
+
16
20
  VERSION = "HTTP/2"
17
21
 
22
+ # @returns [Boolean] Whether the protocol supports bidirectional communication.
18
23
  def self.bidirectional?
19
24
  true
20
25
  end
21
26
 
27
+ # @returns [Boolean] Whether the protocol supports trailers.
22
28
  def self.trailer?
23
29
  true
24
30
  end
25
31
 
32
+ # The default settings for the client.
26
33
  CLIENT_SETTINGS = {
27
34
  ::Protocol::HTTP2::Settings::ENABLE_PUSH => 0,
28
35
  ::Protocol::HTTP2::Settings::MAXIMUM_FRAME_SIZE => 0x100000,
@@ -30,6 +37,7 @@ module Async
30
37
  ::Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES => 1,
31
38
  }
32
39
 
40
+ # The default settings for the server.
33
41
  SERVER_SETTINGS = {
34
42
  # We choose a lower maximum concurrent streams to avoid overloading a single connection/thread.
35
43
  ::Protocol::HTTP2::Settings::MAXIMUM_CONCURRENT_STREAMS => 128,
@@ -39,7 +47,11 @@ module Async
39
47
  ::Protocol::HTTP2::Settings::NO_RFC7540_PRIORITIES => 1,
40
48
  }
41
49
 
42
- def self.client(peer, settings = CLIENT_SETTINGS)
50
+ # Create a client for an outbound connection.
51
+ #
52
+ # @parameter peer [IO] The peer to communicate with.
53
+ # @parameter options [Hash] Options to pass to the client instance.
54
+ def self.client(peer, settings: CLIENT_SETTINGS)
43
55
  stream = ::IO::Stream(peer)
44
56
  client = Client.new(stream)
45
57
 
@@ -49,7 +61,11 @@ module Async
49
61
  return client
50
62
  end
51
63
 
52
- def self.server(peer, settings = SERVER_SETTINGS)
64
+ # Create a server for an inbound connection.
65
+ #
66
+ # @parameter peer [IO] The peer to communicate with.
67
+ # @parameter options [Hash] Options to pass to the server instance.
68
+ def self.server(peer, settings: SERVER_SETTINGS)
53
69
  stream = ::IO::Stream(peer)
54
70
  server = Server.new(stream)
55
71
 
@@ -59,6 +75,7 @@ module Async
59
75
  return server
60
76
  end
61
77
 
78
+ # @returns [Array] The names of the supported protocol.
62
79
  def self.names
63
80
  ["h2"]
64
81
  end
@@ -4,16 +4,18 @@
4
4
  # Copyright, 2018-2024, by Samuel Williams.
5
5
  # Copyright, 2019, by Brian Morearty.
6
6
 
7
+ require_relative "defaulton"
8
+
7
9
  require_relative "http10"
8
10
  require_relative "http11"
9
-
10
11
  require_relative "http2"
11
12
 
12
13
  module Async
13
14
  module HTTP
14
15
  module Protocol
15
16
  # A server that supports both HTTP1.0 and HTTP1.1 semantics by detecting the version of the request.
16
- module HTTPS
17
+ class HTTPS
18
+ # The protocol classes for each supported protocol.
17
19
  HANDLERS = {
18
20
  "h2" => HTTP2,
19
21
  "http/1.1" => HTTP11,
@@ -21,11 +23,27 @@ module Async
21
23
  nil => HTTP11,
22
24
  }
23
25
 
24
- def self.protocol_for(peer)
26
+ def initialize(handlers = HANDLERS, **options)
27
+ @handlers = handlers
28
+ @options = options
29
+ end
30
+
31
+ def add(name, protocol, **options)
32
+ @handlers[name] = protocol
33
+ @options[protocol] = options
34
+ end
35
+
36
+ # Determine the protocol of the peer and return the appropriate protocol class.
37
+ #
38
+ # Use TLS Application Layer Protocol Negotiation (ALPN) to determine the protocol.
39
+ #
40
+ # @parameter peer [IO] The peer to communicate with.
41
+ # @returns [Class] The protocol class to use.
42
+ def protocol_for(peer)
25
43
  # alpn_protocol is only available if openssl v1.0.2+
26
44
  name = peer.alpn_protocol
27
45
 
28
- Console.logger.debug(self) {"Negotiating protocol #{name.inspect}..."}
46
+ Console.debug(self) {"Negotiating protocol #{name.inspect}..."}
29
47
 
30
48
  if protocol = HANDLERS[name]
31
49
  return protocol
@@ -34,18 +52,34 @@ module Async
34
52
  end
35
53
  end
36
54
 
37
- def self.client(peer)
38
- protocol_for(peer).client(peer)
55
+ # Create a client for an outbound connection.
56
+ #
57
+ # @parameter peer [IO] The peer to communicate with.
58
+ # @parameter options [Hash] Options to pass to the client instance.
59
+ def client(peer, **options)
60
+ protocol = protocol_for(peer)
61
+ options = options[protocol] || {}
62
+
63
+ protocol.client(peer, **options)
39
64
  end
40
65
 
41
- def self.server(peer)
42
- protocol_for(peer).server(peer)
66
+ # Create a server for an inbound connection.
67
+ #
68
+ # @parameter peer [IO] The peer to communicate with.
69
+ # @parameter options [Hash] Options to pass to the server instance.
70
+ def server(peer, **options)
71
+ protocol = protocol_for(peer)
72
+ options = options[protocol] || {}
73
+
74
+ protocol.server(peer, **options)
43
75
  end
44
76
 
45
- # Supported Application Layer Protocol Negotiation names:
46
- def self.names
47
- HANDLERS.keys.compact
77
+ # @returns [Array] The names of the supported protocol, used for Application Layer Protocol Negotiation (ALPN).
78
+ def names
79
+ @handlers.keys.compact
48
80
  end
81
+
82
+ extend Defaulton
49
83
  end
50
84
  end
51
85
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2024, by Samuel Williams.
4
+ # Copyright, 2019-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "client"
7
7
  require_relative "endpoint"
@@ -82,7 +82,7 @@ module Async
82
82
  def connect(&block)
83
83
  input = Body::Writable.new
84
84
 
85
- response = @client.connect(@address.to_s, @headers, input)
85
+ response = @client.connect(authority: @address, headers: @headers, body: input)
86
86
 
87
87
  if response.success?
88
88
  pipe = Body::Pipe.new(response.body, input)
@@ -45,14 +45,14 @@ module Async
45
45
  def accept(peer, address, task: Task.current)
46
46
  connection = @protocol.server(peer)
47
47
 
48
- Console.logger.debug(self) {"Incoming connnection from #{address.inspect} to #{@protocol}"}
48
+ Console.debug(self) {"Incoming connnection from #{address.inspect} to #{@protocol}"}
49
49
 
50
50
  connection.each do |request|
51
51
  # We set the default scheme unless it was otherwise specified.
52
52
  # https://tools.ietf.org/html/rfc7230#section-5.5
53
53
  request.scheme ||= self.scheme
54
54
 
55
- # Console.logger.debug(self) {"Incoming request from #{address.inspect}: #{request.method} #{request.path}"}
55
+ # Console.debug(self) {"Incoming request from #{address.inspect}: #{request.method} #{request.path}"}
56
56
 
57
57
  # If this returns nil, we assume that the connection has been hijacked.
58
58
  self.call(request)
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module HTTP
8
- VERSION = "0.86.0"
8
+ VERSION = "0.88.0"
9
9
  end
10
10
  end
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2017-2024, by Samuel Williams.
3
+ Copyright, 2017-2025, by Samuel Williams.
4
4
  Copyright, 2018, by Viacheslav Koval.
5
5
  Copyright, 2018, by Janko Marohnić.
6
6
  Copyright, 2019, by Denis Talakevich.
data/readme.md CHANGED
@@ -16,6 +16,14 @@ Please see the [project documentation](https://socketry.github.io/async-http/) f
16
16
 
17
17
  Please see the [project releases](https://socketry.github.io/async-http/releases/index) for all releases.
18
18
 
19
+ ### v0.88.0
20
+
21
+ - [Support custom protocols with options](https://socketry.github.io/async-http/releases/index#support-custom-protocols-with-options)
22
+
23
+ ### v0.87.0
24
+
25
+ - [Unify HTTP/1 and HTTP/2 `CONNECT` semantics](https://socketry.github.io/async-http/releases/index#unify-http/1-and-http/2-connect-semantics)
26
+
19
27
  ### v0.86.0
20
28
 
21
29
  - Add support for HTTP/2 `NO_RFC7540_PRIORITIES`. See <https://www.rfc-editor.org/rfc/rfc9218.html> for more details.
@@ -51,10 +59,6 @@ Please see the [project releases](https://socketry.github.io/async-http/releases
51
59
 
52
60
  - [`Async::HTTP::Internet` accepts keyword arguments](https://socketry.github.io/async-http/releases/index#async::http::internet-accepts-keyword-arguments)
53
61
 
54
- ### v0.73.0
55
-
56
- - [Update support for `interim_response`](https://socketry.github.io/async-http/releases/index#update-support-for-interim_response)
57
-
58
62
  ## See Also
59
63
 
60
64
  - [benchmark-http](https://github.com/socketry/benchmark-http) — A benchmarking tool to report on web server concurrency.
data/releases.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Releases
2
2
 
3
+ ## v0.88.0
4
+
5
+ ### Support custom protocols with options
6
+
7
+ {ruby Async::HTTP::Protocol} contains classes for specific protocols, e.g. {ruby Async::HTTP::Protocol::HTTP1} and {ruby Async::HTTP::Protocol::HTTP2}. It also contains classes for aggregating protocols, e.g. {ruby Async::HTTP::Protocol::HTTP} and {ruby Async::HTTP::Protocol::HTTPS}. They serve as factories for creating client and server instances.
8
+
9
+ These classes are now configurable with various options, which are passed as keyword arguments to the relevant connection classes. For example, to configure an HTTP/1.1 protocol without keep-alive:
10
+
11
+ ``` ruby
12
+ protocol = Async::HTTP::Protocol::HTTP1.new(persistent: false, maximum_line_length: 32)
13
+ endpoint = Async::HTTP::Endpoint.parse("http://localhost:9292", protocol: protocol)
14
+ server = Async::HTTP::Server.for(endpoint) do |request|
15
+ Protocol::HTTP::Response[200, {}, ["Hello, world"]]
16
+ end.run
17
+ ```
18
+
19
+ Making a request to the server will now close the connection after the response is received:
20
+
21
+ > curl -v http://localhost:9292
22
+ * Host localhost:9292 was resolved.
23
+ * IPv6: ::1
24
+ * IPv4: 127.0.0.1
25
+ * Trying [::1]:9292...
26
+ * Connected to localhost (::1) port 9292
27
+ * using HTTP/1.x
28
+ > GET / HTTP/1.1
29
+ > Host: localhost:9292
30
+ > User-Agent: curl/8.12.1
31
+ > Accept: */*
32
+ >
33
+ * Request completely sent off
34
+ < HTTP/1.1 200 OK
35
+ < connection: close
36
+ < content-length: 12
37
+ <
38
+ * shutting down connection #0
39
+ Hello, world
40
+
41
+ In addition, any line longer than 32 bytes will be rejected:
42
+
43
+ curl -v http://localhost:9292/012345678901234567890123456789012
44
+ * Host localhost:9292 was resolved.
45
+ * IPv6: ::1
46
+ * IPv4: 127.0.0.1
47
+ * Trying [::1]:9292...
48
+ * Connected to localhost (::1) port 9292
49
+ * using HTTP/1.x
50
+ > GET /012345678901234567890123456789012 HTTP/1.1
51
+ > Host: localhost:9292
52
+ > User-Agent: curl/8.12.1
53
+ > Accept: */*
54
+ >
55
+ * Request completely sent off
56
+ * Empty reply from server
57
+ * shutting down connection #0
58
+ curl: (52) Empty reply from server
59
+
60
+ ## v0.87.0
61
+
62
+ ### Unify HTTP/1 and HTTP/2 `CONNECT` semantics
63
+
64
+ HTTP/1 has a request line "target" which takes different forms depending on the kind of request. For `CONNECT` requests, the target is the authority (host and port) of the target server, e.g.
65
+
66
+ CONNECT example.com:443 HTTP/1.1
67
+
68
+ In HTTP/2, the `CONNECT` method uses the `:authority` pseudo-header to specify the target, e.g.
69
+
70
+ ``` http
71
+ [HEADERS FRAME]
72
+ :method: connect
73
+ :authority: example.com:443
74
+ ```
75
+
76
+ In HTTP/1, the `Request#path` attribute was previously used to store the target, and this was incorrectly mapped to the `:path` pseudo-header in HTTP/2. This has been corrected, and the `Request#authority` attribute is now used to store the target for both HTTP/1 and HTTP/2, and mapped accordingly. Thus, to make a `CONNECT` request, you should set the `Request#authority` attribute, e.g.
77
+
78
+ ``` ruby
79
+ response = client.connect(authority: "example.com:443")
80
+ ```
81
+
82
+ For HTTP/1, the authority is mapped back to the request line target, and for HTTP/2, it is mapped to the `:authority` pseudo-header.
83
+
3
84
  ## v0.86.0
4
85
 
5
86
  - Add support for HTTP/2 `NO_RFC7540_PRIORITIES`. See <https://www.rfc-editor.org/rfc/rfc9218.html> for more details.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-http
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.86.0
4
+ version: 0.88.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -26,7 +26,6 @@ authors:
26
26
  - Trevor Turk
27
27
  - Viacheslav Koval
28
28
  - dependabot[bot]
29
- autorequire:
30
29
  bindir: bin
31
30
  cert_chain:
32
31
  - |
@@ -58,7 +57,7 @@ cert_chain:
58
57
  Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
59
58
  voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
60
59
  -----END CERTIFICATE-----
61
- date: 2024-12-01 00:00:00.000000000 Z
60
+ date: 2025-03-13 00:00:00.000000000 Z
62
61
  dependencies:
63
62
  - !ruby/object:Gem::Dependency
64
63
  name: async
@@ -117,77 +116,75 @@ dependencies:
117
116
  - !ruby/object:Gem::Version
118
117
  version: '0.6'
119
118
  - !ruby/object:Gem::Dependency
120
- name: protocol-http
119
+ name: metrics
121
120
  requirement: !ruby/object:Gem::Requirement
122
121
  requirements:
123
122
  - - "~>"
124
123
  - !ruby/object:Gem::Version
125
- version: '0.43'
124
+ version: '0.12'
126
125
  type: :runtime
127
126
  prerelease: false
128
127
  version_requirements: !ruby/object:Gem::Requirement
129
128
  requirements:
130
129
  - - "~>"
131
130
  - !ruby/object:Gem::Version
132
- version: '0.43'
131
+ version: '0.12'
133
132
  - !ruby/object:Gem::Dependency
134
- name: protocol-http1
133
+ name: protocol-http
135
134
  requirement: !ruby/object:Gem::Requirement
136
135
  requirements:
137
- - - ">="
136
+ - - "~>"
138
137
  - !ruby/object:Gem::Version
139
- version: 0.28.1
138
+ version: '0.49'
140
139
  type: :runtime
141
140
  prerelease: false
142
141
  version_requirements: !ruby/object:Gem::Requirement
143
142
  requirements:
144
- - - ">="
143
+ - - "~>"
145
144
  - !ruby/object:Gem::Version
146
- version: 0.28.1
145
+ version: '0.49'
147
146
  - !ruby/object:Gem::Dependency
148
- name: protocol-http2
147
+ name: protocol-http1
149
148
  requirement: !ruby/object:Gem::Requirement
150
149
  requirements:
151
150
  - - "~>"
152
151
  - !ruby/object:Gem::Version
153
- version: '0.22'
152
+ version: '0.30'
154
153
  type: :runtime
155
154
  prerelease: false
156
155
  version_requirements: !ruby/object:Gem::Requirement
157
156
  requirements:
158
157
  - - "~>"
159
158
  - !ruby/object:Gem::Version
160
- version: '0.22'
159
+ version: '0.30'
161
160
  - !ruby/object:Gem::Dependency
162
- name: traces
161
+ name: protocol-http2
163
162
  requirement: !ruby/object:Gem::Requirement
164
163
  requirements:
165
164
  - - "~>"
166
165
  - !ruby/object:Gem::Version
167
- version: '0.10'
166
+ version: '0.22'
168
167
  type: :runtime
169
168
  prerelease: false
170
169
  version_requirements: !ruby/object:Gem::Requirement
171
170
  requirements:
172
171
  - - "~>"
173
172
  - !ruby/object:Gem::Version
174
- version: '0.10'
173
+ version: '0.22'
175
174
  - !ruby/object:Gem::Dependency
176
- name: metrics
175
+ name: traces
177
176
  requirement: !ruby/object:Gem::Requirement
178
177
  requirements:
179
178
  - - "~>"
180
179
  - !ruby/object:Gem::Version
181
- version: '0.12'
180
+ version: '0.10'
182
181
  type: :runtime
183
182
  prerelease: false
184
183
  version_requirements: !ruby/object:Gem::Requirement
185
184
  requirements:
186
185
  - - "~>"
187
186
  - !ruby/object:Gem::Version
188
- version: '0.12'
189
- description:
190
- email:
187
+ version: '0.10'
191
188
  executables: []
192
189
  extensions: []
193
190
  extra_rdoc_files: []
@@ -207,6 +204,8 @@ files:
207
204
  - lib/async/http/mock.rb
208
205
  - lib/async/http/mock/endpoint.rb
209
206
  - lib/async/http/protocol.rb
207
+ - lib/async/http/protocol/configurable.rb
208
+ - lib/async/http/protocol/defaulton.rb
210
209
  - lib/async/http/protocol/http.rb
211
210
  - lib/async/http/protocol/http1.rb
212
211
  - lib/async/http/protocol/http1/client.rb
@@ -244,7 +243,6 @@ licenses:
244
243
  metadata:
245
244
  documentation_uri: https://socketry.github.io/async-http/
246
245
  source_code_uri: https://github.com/socketry/async-http.git
247
- post_install_message:
248
246
  rdoc_options: []
249
247
  require_paths:
250
248
  - lib
@@ -259,8 +257,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
259
257
  - !ruby/object:Gem::Version
260
258
  version: '0'
261
259
  requirements: []
262
- rubygems_version: 3.5.22
263
- signing_key:
260
+ rubygems_version: 3.6.2
264
261
  specification_version: 4
265
262
  summary: A HTTP client and server library.
266
263
  test_files: []
metadata.gz.sig CHANGED
Binary file