Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[RubyKaigi 2026] Require Hooks

[RubyKaigi 2026] Require Hooks

Ruby is famously extensible: open classes, included, inherited and such, TracePoint, and more. Yet when it comes to intercepting the process of loading (or requiring) source code, we're left without a standard mechanism to do so.

Transpilers, load flow analyzers, runtime type checkers—all of these could benefit from having a standard API for hijacking require calls. What if we had one? Actually, we do.

Let me introduce the require-hooks gem, which brings a universal interface for intercepting require (and load) calls in Ruby, allowing you to perform source transformations and more.

Avatar for Vladimir Dementyev

Vladimir Dementyev

April 23, 2026

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. Can I intercept a method on a class I don't

    own? ⾃分のものではないクラスのメソッドを 横取りできる? Module#prepend
  2. Can I intercept a Ruby file being loaded and transform

    it? Rubyファイルの読み込みを横取りして変 換できる? ...
  3. CASE #1 NO BUILD TRANSPILING ソースコードを実⾏時にトランスパイルする 19 palkan_tula # some_boot.rb

    # Activate runtime transpling require "ruby-next/language/runtime" # All required files are transpiled on-the-fly require "environment" require "server" # ...
  4. PROOF OF CONCEPT module Kernel module_function alias_method :require_without_ruby_next, :require def

    require(path) RubyNext.require(path) rescue => e warn "Failed to require '#{path}': #{e.message}" require_without_ruby_next(path) end end palkan_tula 20
  5. REPEAT YOURSELF 繰り返しは問題ない 23 palkan_tula module Kernel module_function alias_method :require_without_freezolite,

    :require def require(path) return require_without_freezolite(path) unless Freezolite.target?(path) RubyVM::InstructionSequence.compile_option = {frozen_string_literal: true} require_without_freezolite(path) ensure RubyVM::InstructionSequence.compile_option = {frozen_string_literal: false} end end
  6. CASE #3 EVERY RAILS APPLICATION 繰り返しは本当に問題ない? 24 palkan_tula # zeitwerk/core_ext/kernel.rb

    module Kernel alias_method :zeitwerk_original_require, :require def require(path) if loader = Zeitwerk::Registry.autoloads.registered?(path) if path.end_with?(".rb") required = zeitwerk_original_require(path) loader.__on_file_autoloaded(path) if required required else loader.__on_dir_autoloaded(path) true end else # ... end end end
  7. PERL: @INC hooks palkan_tula 28 # Prepend "use strict" to

    every loaded file unshift @INC, sub { my ($self, $filename) = @_; # Find the file ourselves for my $dir (grep { !ref } @INC) { my $path = "$dir/$filename"; next unless -f $path; open my $fh, '<', $path or next; my $prepend = "use strict; use warnings;\n"; return (\$prepend, $fh); } return; # not found, let others hooks handle it };
  8. PYTHON: sys.meta_path palkan_tula 29 import sys, importlib.abc class MyHook(importlib.abc.MetaPathFinder, importlib.abc.Loader):

    def find_spec(self, fullname, path, target=None): if should_handle(fullname): return importlib.util.spec_from_loader( fullname, self) return None # skip — let others handle it def exec_module(self, module): # load or transform source here ... sys.meta_path.insert(0, MyHook())
  9. NODE.JS: registerHooks palkan_tula 30 import { readFileSync } from 'node:fs';

    import { registerHooks } from 'node:module'; import coffeescript from 'coffeescript'; const extensionsRegex = /\.(coffee|litcoffee|coffee\.md)$/; function load(url, context, nextLoad) { if (extensionsRegex.test(url)) { const { source: rawSource } = nextLoad(url, { ...context, format: 'coffee' }); const transformedSource = coffeescript.compile(rawSource.toString(), url); return { format: getPackageType(url), shortCircuit: true, source: transformedSource, }; } return nextLoad(url, context); } registerHooks({ load });
  10. COMMON PATTERNS Resolvers (path to path) Loaders (path to source

    or full hijack) Chaining palkan_tula 31 ソースパスの独⾃解決 パスからソースまたはバイトコードへの独⾃ロジック フックの連鎖
  11. COMMON USE CASES Mocking Transforming Remote sources Encrypted sources palkan_tula

    32 テストでのモックとスタブ ソースコードの変換 リモートソースからのコード読み込み 暗号化されたソースコードの読み込み
  12. REQUIRE PHASES palkan_tula 34 Loaded? Resolve Read Parse Compile path/name

    absolute path no yes Skip Eval source AST bytecode #load
  13. REQUIRE PHASES palkan_tula 35 Loaded? Resolve Read Parse Compile path/name

    absolute path no yes Skip Eval source AST bytecode #load Hook?
  14. gem "require-hooks" API for intercepting #require/#load/etc MRI, JRuby, TruffleRuby Passes*

    ruby/spec * No #load(path, wrap: ...) github.com/ruby-next/require-hooks 37 palkan_tula
  15. 38 palkan_tula RequireHooks.around_load(patterns: ["/gem/dir/*.rb"]) do |path, &block| puts "Loading #{path}"

    block.call.tap { puts "Loaded #{path}" } end 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}" end 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 end
  16. 39 palkan_tula RequireHooks.around_load(patterns: ["/gem/dir/*.rb"]) do |path, &block| puts "Loading #{path}"

    block.call.tap { puts "Loaded #{path}" } end 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}" end 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 end Around hooks allow wrapping of code loading (e.g., instrumentation) Aroundフックでコード読み込みを ラップ可能(例:計測)
  17. 40 palkan_tula RequireHooks.around_load(patterns: ["/gem/dir/*.rb"]) do |path, &block| puts "Loading #{path}"

    block.call.tap { puts "Loaded #{path}" } end 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}" end 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 end Source transformation hooks (ad hoc transpiling) ソース変換フック (アドホックなトランスパイル)
  18. 41 palkan_tula RequireHooks.around_load(patterns: ["/gem/dir/*.rb"]) do |path, &block| puts "Loading #{path}"

    block.call.tap { puts "Loaded #{path}" } end 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}" end 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 end Full control of what's being loaded 何が読み込まれるか完全に制御
  19. REQUIRE HOOKS palkan_tula 42 Loaded? Resolve Read Compile path/name absolute

    path yes Skip Eval source AST bytecode #load no new source #source_transform Parse #hijack_load no yes #around_load
  20. REQUIRE HOOKS palkan_tula 43 Loaded? Resolve Read Compile path/name absolute

    path yes Skip Eval source AST bytecode #load no new source #source_transform Parse #hijack_load no yes #around_load $LOAD_PATH.unshift(...) ruby-next
  21. palkan_tula 50 Loaded? Resolve Read Compile path/name absolute path yes

    Skip Eval source AST bytecode #load no #load_iseq Parse nil iseq MRI's #load_iseq
  22. gem "bootsnap" Speeds up a Ruby program boot Bytecode, JSON/YAML

    and $LOAD_PATH caching Monopolizes #load_iseq github.com/rails/bootsnap 51 palkan_tula
  23. palkan_tula 54 Loaded? Resolve Read Compile path/name absolute path yes

    Skip Eval source AST bytecode #load no #load_iseq Parse nil iseq How to #around_load? #around_load ???
  24. 55 palkan_tula module RequireHooks::LoadIseq def load_iseq(path) ctx = RequireHooks.context_for(path) ctx.run_around_load_callbacks(path)

    do iseq = if ctx.source_transform? || ctx.hijack? new_contents = ctx.perform_source_transform(path) hijacked = ctx.try_hijack_load(path, new_contents) if hijacked hijacked elsif new_contents RubyVM::InstructionSequence.compile(new_contents, path, path, 1) end end iseq ||= (defined?(super) ? super : RubyVM::InstructionSequence.compile_file(path)) iseq.eval RubyVM::InstructionSequence.compile("") end end end
  25. 56 palkan_tula module RequireHooks::LoadIseq def load_iseq(path) ctx = RequireHooks.context_for(path) ctx.run_around_load_callbacks(path)

    do iseq = if ctx.source_transform? || ctx.hijack? new_contents = ctx.perform_source_transform(path) hijacked = ctx.try_hijack_load(path, new_contents) if hijacked hijacked elsif new_contents RubyVM::InstructionSequence.compile(new_contents, path, path, 1) end end iseq ||= (defined?(super) ? super : RubyVM::InstructionSequence.compile_file(path)) iseq.eval RubyVM::InstructionSequence.compile("") end end end Always compile and eval
  26. 57 palkan_tula module RequireHooks::LoadIseq def load_iseq(path) ctx = RequireHooks.context_for(path) ctx.run_around_load_callbacks(path)

    do iseq = if ctx.source_transform? || ctx.hijack? new_contents = ctx.perform_source_transform(path) hijacked = ctx.try_hijack_load(path, new_contents) if hijacked hijacked elsif new_contents RubyVM::InstructionSequence.compile(new_contents, path, path, 1) end end iseq ||= (defined?(super) ? super : RubyVM::InstructionSequence.compile_file(path)) iseq.eval RubyVM::InstructionSequence.compile("") end end end Return empty iseq to let MRI finish the feature loading without loading the code 空のiseqを返して、コードを 読み込まずにMRIの feature読み込みを完了させる HACK #2
  27. palkan_tula palkan RubyKaigi‘20 59 hyperfine 'ruby project/project.rb' 'HOOKS=idle ruby project/

    project.rb' 'HOOKS=around ruby project/project.rb' Benchmark 1: ruby project/project.rb Time (mean ± σ): 2.755 s ± 0.033 s Benchmark 2: HOOKS=idle ruby project/project.rb Time (mean ± σ): 2.757 s ± 0.010 s Benchmark 3: HOOKS=around ruby project/project.rb Time (mean ± σ): 2.772 s ± 0.011 s Summary ruby project/project.rb ran 1.01 ± 0.01 times faster than HOOKS=idle ruby project/ project.rb 1.01 ± 0.01 times faster than HOOKS=around ruby project/ project.rb ➜ BENCHMARK: 12K FILES
  28. palkan_tula palkan RubyKaigi‘20 60 hyperfine 'HOOKS=around ruby project/project.rb' \ 'REQUIRE_HOOKS_MODE=patch

    HOOKS=around ruby project/project.rb' Benchmark 1: HOOKS=around ruby project/project.rb Time (mean ± σ): 3.111 s ± 0.007 s Benchmark 2: REQUIRE_HOOKS_MODE=patch HOOKS=around ruby project/ project.rb Time (mean ± σ): 3.469 s ± 0.020 s Summary HOOKS=around ruby project/project.rb ran 1.13 ± 0.01 times faster than REQUIRE_HOOKS_MODE=patch HOOKS=around ruby project/project.rb ➜ #load_iseq vs Kernel#require
  29. REQUIRE PHASES palkan_tula 61 Loaded? Resolve Read Parse Compile path/name

    absolute path no yes Skip Eval source AST bytecode
  30. palkan_tula REQUIRE PHASES 62 Loaded? Resolve path/name absolute path no

    yes Skip $LOAD_PATH scan $LOADED_FEATURES check
  31. palkan_tula REQUIRE PHASES 63 Loaded? Resolve path/name absolute path no

    yes Skip $LOAD_PATH scan $LOADED_FEATURES check
  32. 64 palkan_tula module RequireHooks::KernelPatch alias_method :require_without_require_hooks, :require def require(path) _,

    realpath = *$LOAD_PATH.resolve_feature_path(path) return require_without_require_hooks(path) unless realpath ctx = RequireHooks.context_for(realpath) return require_without_require_hooks(path) if ctx.empty? return false if $LOADED_FEATURES.include?(realpath) RequireHooks::KernelPatch.lock_feature(feature) do |loaded| return false if loaded $LOADED_FEATURES << realpath RequireHooks::KernelPatch.load(realpath) true end end end
  33. 65 palkan_tula module RequireHooks::KernelPatch alias_method :require_without_require_hooks, :require def require(path) _,

    realpath = *$LOAD_PATH.resolve_feature_path(path) return require_without_require_hooks(path) unless realpath ctx = RequireHooks.context_for(realpath) return require_without_require_hooks(path) if ctx.empty? return false if $LOADED_FEATURES.include?(realpath) RequireHooks::KernelPatch.lock_feature(feature) do |loaded| return false if loaded $LOADED_FEATURES << realpath RequireHooks::KernelPatch.load(realpath) true end end end Ruby implementations are not designed for that Rubyの実装はこのような使い⽅を 想定していない
  34. PATCHING CAVEATS Array#include? can be slow (Ruby uses an internal

    index) Manually modifying $LOAD_FEATURES can cause expensive invalidation パッチ適⽤の注意点 66 palkan_tula Array#include?は遅くなりうる(内部インデックスで⾼速化しているため) $LOAD_FEATURESの⼿動変更はコストの⾼い無効化を引き起こす可能性がある
  35. 67 palkan_tula static st_table * get_loaded_features_index(const rb_box_t *box) { int

    i; VALUE features = box->loaded_features; const VALUE snapshot = box->loaded_features_snapshot; if (!rb_ary_shared_with_p(snapshot, features)) { /* The sharing was broken; something (other than us in rb_provide_feature()) modified loaded_features. Rebuild the index. */ st_foreach(box->loaded_features_index, loaded_features_index_clear_i, 0); VALUE realpaths = box->loaded_features_realpaths; VALUE realpath_map = box->loaded_features_realpath_map; VALUE previous_realpath_map = rb_hash_dup(realpath_map); rb_hash_clear(realpaths); rb_hash_clear(realpath_map); features = rb_ary_resurrect(features); for (i = 0; i < RARRAY_LEN(features); i++) { VALUE entry, as_str; as_str = entry = rb_ary_entry(features, i); StringValue(as_str); as_str = rb_fstring(as_str);
  36. palkan_tula palkan RubyKaigi‘20 68 hyperfine 'ruby project/project.rb' \ 'HOOKS=around ruby

    project/project.rb' Benchmark 1: ruby project/project.rb Time (mean ± σ): 1.886 s ± 0.130 s Benchmark 2: HOOKS=around ruby project/project.rb Time (mean ± σ): 13.467 s ± 0.410 s Summary ruby project/project.rb ran 7.14 ± 0.16 times faster than HOOKS=around ruby project/ project.rb ➜ BENCHMARK: JRuby, 2.5k files
  37. palkan_tula palkan RubyKaigi‘20 69 hyperfine 'ruby project/project.rb' \ 'HOOKS=around ruby

    project/project.rb' Benchmark 1: ruby project/project.rb Time (mean ± σ): 1.886 s ± 0.130 s Benchmark 2: HOOKS=around ruby project/project.rb Time (mean ± σ): 13.467 s ± 0.410 s Summary ruby project/project.rb ran 7.14 ± 0.16 times faster than HOOKS=around ruby project/ project.rb ➜ BENCHMARK: JRuby, 2.5k files JRuby rebuilds the index on #load when it detects outside changes to $LOADED_FEATURES JRubyは$LOADED_FEATURESの 外部変更を検知すると#load時に インデックスを再構築する
  38. $LOADED_FEATURES+ palkan_tula 71 HACK #3 Smarter $LOADED_FEATURES Faster lookup, less

    invalidations より速い検索、より少ない無効化
  39. palkan_tula GROUNDWORK IDEAS 74 1. Promote $LOAD_FEATURES from Array to

    a full-featured object (w/ check and add APIs) 2. Introduce Ruby::Loader.register_hook and Ruby::Loader::Hook interface $LOAD_FEATURESをArrayから本格的なオブジェクトに昇格(checkとaddのAPIを持つ) Ruby::Loader.register_hookとRuby::Loader::Hookインターフェースの導⼊
  40. GROUNDWORK IDEAS 0. Adopt require-hooks, stop patching Kernel yourself 1.

    Promote $LOAD_FEATURES from Array to a full-featured object (w/ check and add APIs) 2. Introduce Ruby::Loader.register_hook and Ruby::Loader::Hook interface palkan_tula 75 require-hooksを採⽤して、⾃分でKernelをパッチするのをやめよう $LOAD_FEATURESをArrayから本格的なオブジェクトに昇格(checkとaddのAPIを持つ) Ruby::Loader.register_hookとRuby::Loader::Hookインターフェースの導⼊
  41. gem "require-profiler" Profile your application boot process Visualize #require trees

    with Speedscope Know the most requiring gems/folders and who requires who github.com/palkan/require-profiler 79 palkan_tula アプリの起動をプロファイルできる Speedscopeで#requireツリーを可視化できる どのgem・フォルダが重く、何が何をrequireしているかを把握できる