Require Hooks is a library providing universal interface for injecting custom code into the Ruby's loading mechanism. It works on MRI, JRuby, and TruffleRuby.
Require hooks allows you to interfere with Kernel#require (incl. Kernel#require_relative) and Kernel#load.
Add to your Gemfile:
gem "require-hooks"or gemspec:
spec.add_dependency "require-hooks"To enable hooks, you need to load require-hooks/setup before any code that you want to pre-process via hooks:
require "require-hooks/setup"For example, in an application (e.g., Rails), you may want to only process the source files you own, so you must activate Require Hooks after loading the dependencies (e.g., in the config/application.rb file right after Bundler.require(*)).
If you want to pre-process all files, you can activate Require Hooks earlier.
Then, you can add hooks:
- around_load: a hook that wraps code loading operation. Useful for logging and debugging purposes.
# Simple logging
RequireHooks.around_load(patterns: ["/gem/dir/*.rb"]) do |path, &block|
puts "Loading #{path}"
block.call.tap { puts "Loaded #{path}" }
end
# Error enrichment.
# No patterns — all files are affected.
RequireHooks.around_load do |path, &block|
block.call
rescue SyntaxError => e
raise "Oops, your Ruby is not Ruby: #{e.message}"
endThe return value MUST be a result of calling the passed block.
- source_transform: perform source-to-source transformations.
RequireHooks.source_transform(patterns: ["/my_project/*.rb"], exclude_patterns: ["/my_project/vendor/*"]) do |path, source|
source ||= File.read(path)
"# frozen_string_literal: true\n#{source}"
endThe return value MUST be either String (new source code) or nil (indicating that no transformations were performed). The second argument (source) MAY be `nil``, indicating that no transformer tried to transform the source code.
- hijack_load: a hook that is used to manually compile byte code for VM to load it.
# Pattern can be a Proc. If it returns `true`, the hijacker is used.
RequireHooks.hijack_load(patterns: ["/my_project/*.rb"]) do |path, source|
source ||= File.read(path)
if defined?(RubyVM::InstructionSequence)
RubyVM::InstructionSequence.compile(source)
elsif defined?(JRUBY_VERSION)
JRuby.compile(source)
end
endThe return value is platform-specific. If there are multiple hijackers, the first one that returns a non-nil value is used, others are ignored.
NOTE: The patterns and exclude_patterns arguments accept globs as recognized by File.fnmatch.
Depending on the runtime conditions, Require Hooks picks an optimal strategy for injecting the code. You can enforce a particular mode by setting the REQUIRE_HOOKS_MODE env variable (patch, load_iseq or bootsnap). In practice, only setting to patch may makes sense.
If RubyVM::InstructionSequence is available, we use more robust way of hijacking code loading—RubyVM::InstructionSequence#load_iseq.
Keep in mind that if there is already a #load_iseq callback defined, it will only have an effect if Require Hooks hijackers return nil.
In this mode, Require Hooks monkey-patches Kernel#require and friends. This mode is used in JRuby by default.
Bootsnap is a great tool to speed-up your application load and it's included into the default Rails Gemfile. And it uses #load_iseq. Require Hooks activates a custom Bootsnap-compatible mode, so you can benefit from both tools.
You can use require-hooks with Bootsnap to customize code loading. Just make sure you load require-hooks/setup after setting up Bootsnap, for example:
require "bootsnap/setup"
require "require-hooks/setup"The around load hooks are executed for all files independently of whether they are cached or not. Source transformation and hijacking is only done for non-cached files.
Thus, if you introduce new source transformers or hijackers, you must invalidate the cache. (We plan to implement automatic invalidation in future versions.)
Kernel#loadwith a wrap argument (e.g.,load "some_path", trueorload "some_path", MyModule)) is not supported (fallbacked to the original implementation). The biggest challenge here is to support constants nesting.- Some very edgy symlinking scenarios are not supported (unlikely to affect real-world projects).
We conducted a benchmark to measure the performance overhead of Require Hooks using a large Rails project with the following characteristics:
$ find config/ lib/ app/ -name "*.rb" | wc -l
2689$ bundle list | wc -l
427Total number of #require calls: 12741.
We activated Require Hooks in the very start of the program (config/boot.rb).
There is a single around load hook to count all the calls:
counter = 0
RequireHooks.around_load do |_, &block|
counter += 1
block.call
end
at_exit { puts "Total hooked calls: #{counter}" }All tests made with eager_load=true.
Test script: time bundle exec rails runner 'puts "done"'.
| baseline | 29s |
| baseline w/bootsnap | 12s |
| rhooks (iseq) | 30s |
| rhooks (patch) | 8m |
| rhooks (bootsnap) | 12s |
You can see that requiring tons of files with Require Hooks in patch mode is very slow for now. Why? Mostly because we MUST check $LOADED_FEATURES for the presence of the file we want to load and currently we do this via $LOADED_FEATURES.include?(path) call, which becomes very slow when $LOADED_FEATURES is huge. Thus, we recommend activating Require Hooks after loading all the dependencies and limiting the scope of affected files (via the patterns option) on non-MRI platforms to avoid this overhead.
NOTE: Why Ruby's internal implementations is fast despite from doing the same checks? It uses an internal hash table to keep track of the loaded features (vm->loaded_features_realpaths), not an array. Unfortunately, it's not accessible from Ruby.
Here are the numbers for the same project with scoped hooks (only some folders) activated after Bundler.require(*):
- 732 files affected: 2m36s (vs. 30s w/o hooks)
- 153 files affected: 55s (vs. 30s w/o hooks)
Bug reports and pull requests are welcome on GitHub at https://github.com/ruby-next/require-hooks.
The gem is available as open source under the terms of the MIT License.