rails-threaded-proxy 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbc7f9b0318c1136497926119e07a245bacd9d017126add8a1da34e7426d79e0
4
- data.tar.gz: 6506ac75b092ad1530ef54ceaf5d4c75775b3589cb1db2648afef0598e54c9de
3
+ metadata.gz: 2aae2ccc6678cd9afd9e3068a996bfe1d163f0569512a9e37b958c749aeb8096
4
+ data.tar.gz: 14079d5dfc8a7319c54469d859d2cb5e055ba4fae94d80cebda8cb9968bcd076
5
5
  SHA512:
6
- metadata.gz: 38bfa986e8033530bdddcf28a201ce18648bb8d8b46cd7d6738c8addc736aee573f685fc355d45072f6dcd4b632f22e5280891a67d56f7ccb75d9391d92300bb
7
- data.tar.gz: 5a03daa8831574b0dc693916f11a7ebc8bfe8f409578a45e1de82f0f849b60c76dab4c929bf7c91322030f74ce6060df0eb99fdbab28390641df81f195844959
6
+ metadata.gz: eb6a536989c9b554b79ce943ed143e65a1985b7d535dac729cc7b90f01961eda02325134201e6a027d34775cafde12aa084264cce5a9c59c4c88b9b9a31b147f
7
+ data.tar.gz: a4c6056afd3971cf18cd9d2ab5e2e3739b0c76a922c655024d1e34f00069b8cbe42f376c1611c040948798b767663e1a0eb7ad7ec6753af90b4e0fc3727808f9
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2016 James Hu
3
+ Copyright (c) 2024 Michael Nutt
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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.3.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
- METHODS = {
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 = METHODS[request_method]
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
- # We don't support reusing connections once we have disconnected them from rack
59
- client_response['connection'] = 'close'
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
- socket.write "HTTP/1.1 #{client_response.code} #{client_response.message}\r\n"
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
- # Done with headers
72
- socket.write "\r\n"
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.3.0 ruby lib
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.3.0".freeze
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"
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.3.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-14 00:00:00.000000000 Z
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