rails-threaded-proxy 0.3.0 → 0.4.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 +4 -4
- data/LICENSE +1 -1
- data/README.md +46 -0
- data/VERSION +1 -1
- data/lib/threaded_proxy/client.rb +50 -14
- data/lib/threaded_proxy/controller.rb +2 -2
- data/rails-threaded-proxy.gemspec +6 -4
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2aae2ccc6678cd9afd9e3068a996bfe1d163f0569512a9e37b958c749aeb8096
|
4
|
+
data.tar.gz: 14079d5dfc8a7319c54469d859d2cb5e055ba4fae94d80cebda8cb9968bcd076
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eb6a536989c9b554b79ce943ed143e65a1985b7d535dac729cc7b90f01961eda02325134201e6a027d34775cafde12aa084264cce5a9c59c4c88b9b9a31b147f
|
7
|
+
data.tar.gz: a4c6056afd3971cf18cd9d2ab5e2e3739b0c76a922c655024d1e34f00069b8cbe42f376c1611c040948798b767663e1a0eb7ad7ec6753af90b4e0fc3727808f9
|
data/LICENSE
CHANGED
data/README.md
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# rails-threaded-proxy
|
2
|
+
|
3
|
+
Asynchronous high throughput reverse proxy for rails
|
4
|
+
|
5
|
+
*Warning: experimental. Use at your own risk.*
|
6
|
+
|
7
|
+
## About
|
8
|
+
|
9
|
+
Rails concurrency is often limited to running many processes, which can be memory-intensive. Even for servers that support threads, it can be difficult running dozens or hundreds of threads. But you may have backend services that are slow to respond, and/or return very large responses. It is useful to put these services behind rails for authentication, but slow responses can tie up your rails workers preventing them from serving other clients.
|
10
|
+
|
11
|
+
`rails-threaded-proxy` disconnects the proxying from the rack request/response cycle, freeing up workers to serve other clients. It does this by running the origin request in a thread. But running in a thread is not enough: we need to be able to respond to the rails request, but rack owns the socket. So it hijacks the request: rack completes immediately but dissociates from the socket. Then we're free to manage the socket ourselves. Copying between sockets, we can achieve high throughput (100MB/s+) with minimal CPU and memory overhead.
|
12
|
+
|
13
|
+
## Usage
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
class MyController
|
17
|
+
include ThreadedProxy::Controller
|
18
|
+
|
19
|
+
def my_backend
|
20
|
+
proxy_fetch "http://backend.service/path/to/endpoint", method: :post do |config|
|
21
|
+
config.on_headers do |client_response|
|
22
|
+
# override some response headers coming from the backend
|
23
|
+
client_response['content-security-policy'] = "sandbox;"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
```
|
29
|
+
|
30
|
+
## Requirements
|
31
|
+
|
32
|
+
Tested with Rails 7, but probably works in Rails 6+. Needs an application server that supports `rack.hijack`. (only tested on [https://puma.io/](Puma) so far)
|
33
|
+
|
34
|
+
## Caveats
|
35
|
+
|
36
|
+
* There isn't currently a way to limit concurrency. It is possible to run your server out of file descriptors, memory, etc.
|
37
|
+
* Since the proxying happens in a thread, callbacks are also run inside of the thread. Don't do anything non-threadsafe in callbacks.
|
38
|
+
* There is currently probably not sufficient error handling for edge cases. This is experimental.
|
39
|
+
|
40
|
+
## Attribution
|
41
|
+
|
42
|
+
Inspired by [https://github.com/axsuul/rails-reverse-proxy](rails-reverse-proxy), and tries to use similar API structure where possible. If you don't care about the specific benefits of `rails-threaded-proxy`, you should consider using `rails-reverse-proxy` instead.
|
43
|
+
|
44
|
+
## License
|
45
|
+
|
46
|
+
See LICENSE
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.4.0
|
@@ -9,7 +9,7 @@ module ThreadedProxy
|
|
9
9
|
class Client
|
10
10
|
DISALLOWED_RESPONSE_HEADERS = %w[keep-alive].freeze
|
11
11
|
|
12
|
-
|
12
|
+
HTTP_METHODS = {
|
13
13
|
'get' => Net::HTTP::Get,
|
14
14
|
'post' => Net::HTTP::Post,
|
15
15
|
'put' => Net::HTTP::Put,
|
@@ -19,6 +19,20 @@ module ThreadedProxy
|
|
19
19
|
'trace' => Net::HTTP::Trace
|
20
20
|
}.freeze
|
21
21
|
|
22
|
+
CALLBACK_METHODS = %i[
|
23
|
+
on_response
|
24
|
+
on_headers
|
25
|
+
on_body
|
26
|
+
on_complete
|
27
|
+
on_error
|
28
|
+
].freeze
|
29
|
+
|
30
|
+
CALLBACK_METHODS.each do |method_name|
|
31
|
+
define_method(method_name) do |&block|
|
32
|
+
@callbacks[method_name] = block
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
22
36
|
DEFAULT_OPTIONS = {
|
23
37
|
headers: {},
|
24
38
|
debug: false,
|
@@ -28,6 +42,13 @@ module ThreadedProxy
|
|
28
42
|
def initialize(origin_url, options = {})
|
29
43
|
@origin_url = Addressable::URI.parse(origin_url)
|
30
44
|
@options = DEFAULT_OPTIONS.merge(options)
|
45
|
+
|
46
|
+
@callbacks = {}
|
47
|
+
CALLBACK_METHODS.each do |method_name|
|
48
|
+
@callbacks[method_name] = proc {}
|
49
|
+
end
|
50
|
+
|
51
|
+
yield(self) if block_given?
|
31
52
|
end
|
32
53
|
|
33
54
|
def log(message)
|
@@ -38,7 +59,7 @@ module ThreadedProxy
|
|
38
59
|
request_method = @options[:method].to_s.downcase
|
39
60
|
request_headers = @options[:headers].merge('Connection' => 'close')
|
40
61
|
|
41
|
-
request_class =
|
62
|
+
request_class = HTTP_METHODS[request_method]
|
42
63
|
http_request = request_class.new(@origin_url, request_headers)
|
43
64
|
if @options[:body].respond_to?(:read)
|
44
65
|
http_request.body_stream = @options[:body]
|
@@ -55,21 +76,15 @@ module ThreadedProxy
|
|
55
76
|
|
56
77
|
http.start do
|
57
78
|
http.request(http_request) do |client_response|
|
58
|
-
|
59
|
-
|
79
|
+
@callbacks[:on_response].call(client_response, socket)
|
80
|
+
break if socket.closed?
|
60
81
|
|
61
|
-
yield client_response if block_given?
|
62
|
-
|
63
|
-
# start writing response
|
64
82
|
log('Writing response status and headers')
|
65
|
-
|
66
|
-
|
67
|
-
client_response.each_header do |key, value|
|
68
|
-
socket.write "#{key}: #{value}\r\n" unless DISALLOWED_RESPONSE_HEADERS.include?(key.downcase)
|
69
|
-
end
|
83
|
+
write_headers(client_response, socket)
|
84
|
+
break if socket.closed?
|
70
85
|
|
71
|
-
|
72
|
-
socket.
|
86
|
+
@callbacks[:on_body].call(client_response, socket)
|
87
|
+
break if socket.closed?
|
73
88
|
|
74
89
|
# There may have been some existing data in client_response's read buffer, flush it out
|
75
90
|
# before we manually connect the raw sockets
|
@@ -79,11 +94,32 @@ module ThreadedProxy
|
|
79
94
|
# Copy the rest of the client response to the socket
|
80
95
|
log('Copying response body to client')
|
81
96
|
http.copy_to(socket)
|
97
|
+
|
98
|
+
@callbacks[:on_complete].call(client_response)
|
82
99
|
end
|
100
|
+
rescue StandardError => e
|
101
|
+
@callbacks[:on_error].call(e) or raise
|
83
102
|
end
|
84
103
|
end
|
85
104
|
end
|
86
105
|
|
106
|
+
def write_headers(client_response, socket)
|
107
|
+
socket.write "HTTP/1.1 #{client_response.code} #{client_response.message}\r\n"
|
108
|
+
|
109
|
+
# We don't support reusing connections once we have disconnected them from rack
|
110
|
+
client_response['connection'] = 'close'
|
111
|
+
|
112
|
+
@callbacks[:on_headers].call(client_response, socket)
|
113
|
+
return if socket.closed?
|
114
|
+
|
115
|
+
client_response.each_header do |key, value|
|
116
|
+
socket.write "#{key}: #{value}\r\n" unless DISALLOWED_RESPONSE_HEADERS.include?(key.downcase)
|
117
|
+
end
|
118
|
+
|
119
|
+
# Done with headers
|
120
|
+
socket.write "\r\n"
|
121
|
+
end
|
122
|
+
|
87
123
|
def default_port(uri)
|
88
124
|
case uri.scheme
|
89
125
|
when 'http'
|
@@ -4,7 +4,7 @@ require_relative 'client'
|
|
4
4
|
|
5
5
|
module ThreadedProxy
|
6
6
|
module Controller
|
7
|
-
def proxy_fetch(origin_url, options = {})
|
7
|
+
def proxy_fetch(origin_url, options = {}, &block)
|
8
8
|
# hijack the response so we can take it outside of the rack request/response cycle
|
9
9
|
request.env['rack.hijack'].call
|
10
10
|
socket = request.env['rack.hijack_io']
|
@@ -25,7 +25,7 @@ module ThreadedProxy
|
|
25
25
|
options[:headers]['Content-Type'] = request.env['CONTENT_TYPE'] if request.env['CONTENT_TYPE']
|
26
26
|
end
|
27
27
|
|
28
|
-
client = Client.new(origin_url, options)
|
28
|
+
client = Client.new(origin_url, options, &block)
|
29
29
|
client.start(socket)
|
30
30
|
rescue Errno::EPIPE
|
31
31
|
# client disconnected before request finished; not an error
|
@@ -2,21 +2,22 @@
|
|
2
2
|
# DO NOT EDIT THIS FILE DIRECTLY
|
3
3
|
# Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
|
4
4
|
# -*- encoding: utf-8 -*-
|
5
|
-
# stub: rails-threaded-proxy 0.
|
5
|
+
# stub: rails-threaded-proxy 0.4.0 ruby lib
|
6
6
|
|
7
7
|
Gem::Specification.new do |s|
|
8
8
|
s.name = "rails-threaded-proxy".freeze
|
9
|
-
s.version = "0.
|
9
|
+
s.version = "0.4.0".freeze
|
10
10
|
|
11
11
|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
12
12
|
s.require_paths = ["lib".freeze]
|
13
13
|
s.authors = ["Michael Nutt".freeze]
|
14
|
-
s.date = "2024-10-
|
14
|
+
s.date = "2024-10-15"
|
15
15
|
s.description = "Threaded reverse proxy for Ruby on Rails".freeze
|
16
16
|
s.email = "michael@nuttnet.net".freeze
|
17
17
|
s.executables = ["bundle".freeze, "htmldiff".freeze, "jeweler".freeze, "ldiff".freeze, "nokogiri".freeze, "racc".freeze, "rackup".freeze, "rake".freeze, "rdoc".freeze, "ri".freeze, "rspec".freeze, "rubocop".freeze, "semver".freeze]
|
18
18
|
s.extra_rdoc_files = [
|
19
|
-
"LICENSE"
|
19
|
+
"LICENSE",
|
20
|
+
"README.md"
|
20
21
|
]
|
21
22
|
s.files = [
|
22
23
|
".bundle/config",
|
@@ -26,6 +27,7 @@ Gem::Specification.new do |s|
|
|
26
27
|
"Gemfile",
|
27
28
|
"Gemfile.lock",
|
28
29
|
"LICENSE",
|
30
|
+
"README.md",
|
29
31
|
"Rakefile",
|
30
32
|
"VERSION",
|
31
33
|
"bin/bundle",
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rails-threaded-proxy
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Michael Nutt
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-10-
|
11
|
+
date: 2024-10-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: actionpack
|
@@ -155,6 +155,7 @@ executables:
|
|
155
155
|
extensions: []
|
156
156
|
extra_rdoc_files:
|
157
157
|
- LICENSE
|
158
|
+
- README.md
|
158
159
|
files:
|
159
160
|
- ".bundle/config"
|
160
161
|
- ".rspec"
|
@@ -163,6 +164,7 @@ files:
|
|
163
164
|
- Gemfile
|
164
165
|
- Gemfile.lock
|
165
166
|
- LICENSE
|
167
|
+
- README.md
|
166
168
|
- Rakefile
|
167
169
|
- VERSION
|
168
170
|
- bin/bundle
|