vite_rails 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/CONTRIBUTING.md +34 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +64 -0
  6. data/lib/install/binstubs.rb +6 -0
  7. data/lib/install/template.rb +62 -0
  8. data/lib/vite_rails.rb +91 -0
  9. data/lib/vite_rails/builder.rb +113 -0
  10. data/lib/vite_rails/commands.rb +68 -0
  11. data/lib/vite_rails/config.rb +106 -0
  12. data/lib/vite_rails/dev_server.rb +23 -0
  13. data/lib/vite_rails/dev_server_proxy.rb +47 -0
  14. data/lib/vite_rails/engine.rb +40 -0
  15. data/lib/vite_rails/helper.rb +41 -0
  16. data/lib/vite_rails/manifest.rb +134 -0
  17. data/lib/vite_rails/runner.rb +56 -0
  18. data/lib/vite_rails/version.rb +5 -0
  19. data/package.json +28 -0
  20. data/package/default.vite.json +15 -0
  21. data/test/builder_test.rb +72 -0
  22. data/test/command_test.rb +35 -0
  23. data/test/configuration_test.rb +80 -0
  24. data/test/dev_server_runner_test.rb +83 -0
  25. data/test/dev_server_test.rb +39 -0
  26. data/test/engine_rake_tasks_test.rb +42 -0
  27. data/test/helper_test.rb +138 -0
  28. data/test/manifest_test.rb +75 -0
  29. data/test/mode_test.rb +21 -0
  30. data/test/mounted_app/Rakefile +6 -0
  31. data/test/mounted_app/test/dummy/Rakefile +5 -0
  32. data/test/mounted_app/test/dummy/bin/rails +5 -0
  33. data/test/mounted_app/test/dummy/bin/rake +5 -0
  34. data/test/mounted_app/test/dummy/config.ru +7 -0
  35. data/test/mounted_app/test/dummy/config/application.rb +12 -0
  36. data/test/mounted_app/test/dummy/config/environment.rb +5 -0
  37. data/test/mounted_app/test/dummy/config/vite.json +20 -0
  38. data/test/mounted_app/test/dummy/package.json +7 -0
  39. data/test/rake_tasks_test.rb +74 -0
  40. data/test/test_app/Rakefile +5 -0
  41. data/test/test_app/app/javascript/entrypoints/application.js +10 -0
  42. data/test/test_app/app/javascript/entrypoints/multi_entry.css +4 -0
  43. data/test/test_app/app/javascript/entrypoints/multi_entry.js +4 -0
  44. data/test/test_app/bin/vite +17 -0
  45. data/test/test_app/config.ru +7 -0
  46. data/test/test_app/config/application.rb +13 -0
  47. data/test/test_app/config/environment.rb +6 -0
  48. data/test/test_app/config/vite.json +20 -0
  49. data/test/test_app/config/vite_public_root.yml +20 -0
  50. data/test/test_app/package.json +13 -0
  51. data/test/test_app/public/vite/manifest.json +36 -0
  52. data/test/test_app/some.config.js +0 -0
  53. data/test/test_app/yarn.lock +11 -0
  54. data/test/test_helper.rb +34 -0
  55. data/test/vite_runner_test.rb +59 -0
  56. data/test/webpacker_test.rb +15 -0
  57. metadata +234 -0
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public: Allows to resolve configuration sourced from `config/vite.json` and
4
+ # environment variables, combining them with the default options.
5
+ class ViteRails::Config
6
+ delegate :as_json, :inspect, to: :@config
7
+
8
+ def initialize(config)
9
+ @config = config.tap { coerce_values(config) }.freeze
10
+
11
+ config.each_key do |option|
12
+ define_singleton_method(option) { @config[option] }
13
+ end
14
+ end
15
+
16
+ def protocol
17
+ https ? 'https' : 'http'
18
+ end
19
+
20
+ def host_with_port
21
+ "#{ host }:#{ port }"
22
+ end
23
+
24
+ # Internal: Path where Vite outputs the manifest file.
25
+ def manifest_path
26
+ build_output_dir.join('manifest.json')
27
+ end
28
+
29
+ # Public: The directory where Vite will store the built assets.
30
+ def build_output_dir
31
+ public_dir.join(public_output_dir)
32
+ end
33
+
34
+ private
35
+
36
+ # Internal: Coerces all the configuration values, in case they were passed
37
+ # as environment variables which are always strings.
38
+ def coerce_values(config)
39
+ coerce_booleans(config, 'auto_build', 'https')
40
+ coerce_paths(config, 'assets_dir', 'build_cache_dir', 'config_path', 'public_dir', 'source_code_dir', 'public_output_dir', 'root')
41
+ config['port'] = config['port'].to_i
42
+ config['root'] ||= Rails.root
43
+ end
44
+
45
+ # Internal: Coerces configuration options to boolean.
46
+ def coerce_booleans(config, *names)
47
+ names.each { |name| config[name] = [true, 'true'].include?(config[name]) }
48
+ end
49
+
50
+ # Internal: Converts configuration options to pathname.
51
+ def coerce_paths(config, *names)
52
+ names.each { |name| config[name] = Pathname.new(config[name]) unless config[name].nil? }
53
+ end
54
+
55
+ class << self
56
+ # Public: Returns the project configuration for Vite.
57
+ def resolve_config
58
+ new DEFAULT_CONFIG.merge(config_from_file).merge(config_from_env)
59
+ rescue Errno::ENOENT => error
60
+ warn "Check that your vite.json configuration file is available in the load path. #{ error.message }"
61
+ new DEFAULT_CONFIG.merge(config_from_env)
62
+ end
63
+
64
+ private
65
+
66
+ # Internal: Used to load a JSON file from the specified path.
67
+ def load_json(path)
68
+ JSON.parse(File.read(File.expand_path(path))).deep_transform_keys(&:underscore)
69
+ rescue => error
70
+ (require 'pry-byebug';binding.pry;);
71
+ end
72
+
73
+ # Internal: Retrieves a configuration option from environment variables.
74
+ def config_option_from_env(name)
75
+ ENV["#{ ViteRails::ENV_PREFIX }_#{ name.upcase }"]
76
+ end
77
+
78
+ # Internal: Extracts the configuration options provided as env vars.
79
+ def config_from_env
80
+ CONFIGURABLE_WITH_ENV.each_with_object({}) do |key, env_vars|
81
+ if value = config_option_from_env(key)
82
+ env_vars[key] = value
83
+ end
84
+ end.merge(mode: vite_mode)
85
+ end
86
+
87
+ # Internal: The mode Vite should run on.
88
+ def vite_mode
89
+ config_option_from_env('mode') || Rails.env.to_s
90
+ end
91
+
92
+ # Internal: Loads the configuration options provided in a JSON file.
93
+ def config_from_file
94
+ path = config_option_from_env('config_path') || DEFAULT_CONFIG.fetch('config_path')
95
+ multi_env_config = load_json(path)
96
+ multi_env_config.fetch('all', {})
97
+ .merge(multi_env_config.fetch(vite_mode, {}))
98
+ end
99
+ end
100
+
101
+ # Internal: Shared configuration with the Vite plugin for Ruby.
102
+ DEFAULT_CONFIG = load_json("#{ __dir__ }/../../package/default.vite.json").freeze
103
+
104
+ # Internal: Configuration options that can be provided as env vars.
105
+ CONFIGURABLE_WITH_ENV = (DEFAULT_CONFIG.keys + ['root']).freeze
106
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public: Allows to verify if a Vite development server is already running.
4
+ class ViteRails::DevServer
5
+ # Public: Configure dev server connection timeout (in seconds).
6
+ # Example:
7
+ # ViteRails.dev_server.connect_timeout = 1
8
+ cattr_accessor(:connect_timeout) { 0.01 }
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ # Public: Returns true if the Vite development server is reachable.
15
+ def running?
16
+ Socket.tcp(host, port, connect_timeout: connect_timeout).close
17
+ true
18
+ rescue StandardError
19
+ false
20
+ end
21
+
22
+ delegate :host, :port, to: :@config
23
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/proxy'
4
+
5
+ # Public: Allows to relay asset requests to the Vite development server.
6
+ class ViteRails::DevServerProxy < Rack::Proxy
7
+ VITE_DEPENDENCY_PREFIX = '/@'
8
+
9
+ def initialize(app = nil, options = {})
10
+ @vite_rails = options.delete(:vite_rails) || ViteRails.instance
11
+ options[:streaming] = false if Rails.env.test? && !options.key?(:streaming)
12
+ super
13
+ end
14
+
15
+ # Rack: Intercept asset requests and send them to the Vite server.
16
+ def perform_request(env)
17
+ if vite_should_handle?(env['REQUEST_URI']) && dev_server.running?
18
+ env['REQUEST_URI'] = env['REQUEST_URI']
19
+ .sub(vite_asset_url_prefix, '/')
20
+ .sub('.ts.js', '.ts') # Patch: Rails helpers always append the extension.
21
+ env['PATH_INFO'], env['QUERY_STRING'] = env['REQUEST_URI'].split('?')
22
+
23
+ env['HTTP_HOST'] = env['HTTP_X_FORWARDED_HOST'] = config.host
24
+ env['HTTP_X_FORWARDED_SERVER'] = config.host_with_port
25
+ env['HTTP_PORT'] = env['HTTP_X_FORWARDED_PORT'] = config.port.to_s
26
+ env['HTTP_X_FORWARDED_PROTO'] = env['HTTP_X_FORWARDED_SCHEME'] = config.protocol
27
+ env['HTTPS'] = env['HTTP_X_FORWARDED_SSL'] = 'off' unless config.https
28
+ env['SCRIPT_NAME'] = ''
29
+ super(env)
30
+ else
31
+ @app.call(env)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ delegate :config, :dev_server, to: :@vite_rails
38
+
39
+ def vite_should_handle?(url)
40
+ url.start_with?(vite_asset_url_prefix) || url.start_with?(VITE_DEPENDENCY_PREFIX) ||
41
+ url.include?('?t=') # Direct Hot Reload
42
+ end
43
+
44
+ def vite_asset_url_prefix
45
+ @vite_asset_url_prefix ||= "/#{ config.public_output_dir }/"
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ class ViteRails::Engine < Rails::Engine
6
+ initializer 'vite_rails.proxy' do |app|
7
+ app.middleware.insert_before 0, ViteRails::DevServerProxy, ssl_verify_none: true if ViteRails.run_proxy?
8
+ end
9
+
10
+ initializer 'vite_rails.helper' do
11
+ ActiveSupport.on_load(:action_controller) do
12
+ ActionController::Base.helper(ViteRails::Helper)
13
+ end
14
+
15
+ ActiveSupport.on_load(:action_view) do
16
+ include ViteRails::Helper
17
+ end
18
+ end
19
+
20
+ initializer 'vite_rails.logger' do
21
+ config.after_initialize do
22
+ ViteRails.logger = if ::Rails.logger.respond_to?(:tagged)
23
+ ::Rails.logger
24
+ else
25
+ ActiveSupport::TaggedLogging.new(::Rails.logger)
26
+ end
27
+ end
28
+ end
29
+
30
+ initializer 'vite_rails.bootstrap' do
31
+ if defined?(Rails::Server) || defined?(Rails::Console)
32
+ ViteRails.bootstrap
33
+ if defined?(Spring)
34
+ require 'spring/watcher'
35
+ Spring.after_fork { ViteRails.bootstrap }
36
+ Spring.watch(ViteRails.config.config_path)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public: Allows to render HTML tags for scripts and styles processed by Vite.
4
+ module ViteRails::Helper
5
+ # Public: Returns the current Vite Rails instance.
6
+ def current_vite_instance
7
+ ViteRails.instance
8
+ end
9
+
10
+ # Public: Computes the relative path for the specified given Vite asset.
11
+ #
12
+ # Example:
13
+ # <%= vite_asset_path 'calendar.css' %> # => "/vite/assets/calendar-1016838bab065ae1e122.css"
14
+ def vite_asset_path(name, **options)
15
+ current_vite_instance.manifest.lookup!(name, **options)
16
+ end
17
+
18
+ # Public: Renders a <script> tag for the specified Vite entrypoints.
19
+ def vite_javascript_tag(*names, type: 'module', **options)
20
+ javascript_include_tag(*sources_from_vite_manifest_entrypoints(names, type: :javascript), type: type, **options)
21
+ end
22
+
23
+ # Public: Renders a <script> tag for the specified Vite entrypoints.
24
+ #
25
+ # NOTE: Because TypeScript is not a valid target in browsers, we only specify
26
+ # the ts file when running the Vite development server.
27
+ def vite_typescript_tag(*names, type: 'module', **options)
28
+ javascript_include_tag(*sources_from_vite_manifest_entrypoints(names, type: :typescript), type: type, **options)
29
+ end
30
+
31
+ # Public: Renders a <link> tag for the specified Vite entrypoints.
32
+ def vite_stylesheet_tag(*names, **options)
33
+ stylesheet_link_tag(*sources_from_vite_manifest_entrypoints(names, type: :stylesheet), **options)
34
+ end
35
+
36
+ private
37
+
38
+ def sources_from_vite_manifest_entrypoints(names, type:)
39
+ names.flat_map { |name| vite_asset_path(name, type: type) }.uniq
40
+ end
41
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public: Registry for accessing resources managed by Vite, using a generated
4
+ # manifest file which maps entrypoint names to file paths.
5
+ #
6
+ # Example:
7
+ # lookup_entrypoint('calendar', type: :javascript)
8
+ # => "/vite/assets/calendar-1016838bab065ae1e314.js"
9
+ #
10
+ # NOTE: Using "autoBuild": true` in `config/vite.json` file will trigger a build
11
+ # on demand as needed, before performing any lookup.
12
+ class ViteRails::Manifest
13
+ class MissingEntryError < StandardError
14
+ end
15
+
16
+ def initialize(vite_rails)
17
+ @vite_rails = vite_rails
18
+ end
19
+
20
+ # Public: Strict version of lookup.
21
+ #
22
+ # Returns a relative path for the asset, or raises an error if not found.
23
+ def lookup!(*args, **options)
24
+ lookup(*args, **options) || missing_entry_error(*args, **options)
25
+ end
26
+
27
+ # Public: Computes the path for a given Vite asset using manifest.json.
28
+ #
29
+ # Returns a relative path, or nil if the asset is not found.
30
+ #
31
+ # Example:
32
+ # ViteRails.manifest.lookup('calendar.js') # => "/vite/assets/calendar-1016838bab065ae1e122.js"
33
+ def lookup(name, type:)
34
+ build if should_build?
35
+
36
+ find_manifest_entry(with_file_extension(name, type))
37
+ end
38
+
39
+ # Public: Refreshes the cached mappings by reading the updated manifest.
40
+ def refresh
41
+ @manifest = load_manifest
42
+ end
43
+
44
+ private
45
+
46
+ delegate :config, :builder, :dev_server, to: :@vite_rails
47
+
48
+ # Public: Returns true if the Vite development server is running.
49
+ def dev_server_running?
50
+ ViteRails.run_proxy? && dev_server.running?
51
+ end
52
+
53
+ # NOTE: Auto compilation is convenient when running tests, when the developer
54
+ # won't focus on the frontend, or when running the Vite server is not desired.
55
+ def should_build?
56
+ config.auto_build && !dev_server_running?
57
+ end
58
+
59
+ # Internal: Finds the specified entry in the manifest.
60
+ def find_manifest_entry(name)
61
+ if dev_server_running?
62
+ "/#{ config.public_output_dir.join(name.to_s) }"
63
+ elsif file = manifest.dig(name.to_s, 'file')
64
+ "/#{ config.public_output_dir.join(file) }"
65
+ end
66
+ end
67
+
68
+ # Internal: Performs a Vite build.
69
+ def build
70
+ ViteRails.logger.tagged('Vite') { builder.build }
71
+ end
72
+
73
+ # Internal: The parsed data from manifest.json.
74
+ #
75
+ # NOTE: When using build-on-demand in development and testing, the manifest
76
+ # is reloaded automatically before each lookup, to ensure it's always fresh.
77
+ def manifest
78
+ return refresh if config.auto_build
79
+
80
+ @manifest ||= load_manifest
81
+ end
82
+
83
+ # Internal: Returns a Hash with the entries in the manifest.json.
84
+ def load_manifest
85
+ if config.manifest_path.exist?
86
+ JSON.parse(config.manifest_path.read)
87
+ else
88
+ {}
89
+ end
90
+ end
91
+
92
+ # Internal: Adds a file extension to the file name, unless it already has one.
93
+ def with_file_extension(name, entry_type)
94
+ return name unless File.extname(name.to_s).empty?
95
+
96
+ "#{ name }.#{ extension_for_type(entry_type) }"
97
+ end
98
+
99
+ # Internal: Allows to receive :javascript and :stylesheet as :type in helpers.
100
+ def extension_for_type(entry_type)
101
+ case entry_type
102
+ when :javascript then 'js'
103
+ when :stylesheet then 'css'
104
+ when :typescript then dev_server_running? ? 'ts' : 'js'
105
+ else entry_type.to_s
106
+ end
107
+ end
108
+
109
+ # Internal: Raises a detailed message when an entry is missing in the manifest.
110
+ def missing_entry_error(name, type: nil, **_options)
111
+ file_name = with_file_extension(name, type)
112
+ raise ViteRails::Manifest::MissingEntryError, <<~MSG
113
+ Vite Rails can't find #{ file_name } in #{ config.manifest_path }.
114
+
115
+ Possible causes:
116
+ #{ missing_entry_causes.map { |cause| "\t- #{ cause }" }.join("\n") }
117
+
118
+ Your manifest contains:
119
+ #{ JSON.pretty_generate(@manifest) }
120
+ MSG
121
+ end
122
+
123
+ def missing_entry_causes
124
+ local = config.auto_build
125
+ [
126
+ (dev_server_running? && 'Vite has not yet re-built your latest changes.'),
127
+ (local && !dev_server_running? && "\"autoBuild\": false in your #{ config.mode } configuration."),
128
+ 'You have misconfigured config/vite.json file.',
129
+ (!local && 'Assets have not been precompiled'),
130
+ ].select(&:itself)
131
+ rescue StandardError
132
+ []
133
+ end
134
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public: Executes Vite commands, providing conveniences for debugging.
4
+ class ViteRails::Runner
5
+ def initialize(argv)
6
+ detect_unsupported_switches!(argv)
7
+ @argv = argv
8
+ end
9
+
10
+ # Public: Executes Vite with the specified arguments.
11
+ def run
12
+ execute_command(@argv.clone)
13
+ end
14
+
15
+ private
16
+
17
+ UNSUPPORTED_SWITCHES = %w[--host --port --https --root -r --config -c]
18
+ private_constant :UNSUPPORTED_SWITCHES
19
+
20
+ # Internal: Allows to prevent configuration mistakes by ensuring the Rails app
21
+ # and vite-plugin-ruby are using the same configuration for the dev server.
22
+ def detect_unsupported_switches!(args)
23
+ return unless (unsupported = UNSUPPORTED_SWITCHES & args).any?
24
+
25
+ $stdout.puts "Please set the following switches in your vite.json instead: #{ unsupported }."
26
+ exit!
27
+ end
28
+
29
+ # Internal: Executes the command with the specified arguments.
30
+ def execute_command(args)
31
+ cmd = vite_executable
32
+ cmd.prepend('node', '--inspect-brk') if args.include?('--debug')
33
+ cmd.prepend('node', '--trace-deprecation') if args.delete('--trace-deprecation')
34
+ args.append('--mode', ENV['RAILS_ENV']) unless args.include?('--mode') || args.include?('-m')
35
+ cmd += args
36
+ puts cmd.join(' ')
37
+ Dir.chdir(File.expand_path('.', Dir.pwd)) { Kernel.exec(ViteRails.env, *cmd) }
38
+ end
39
+
40
+ # Internal: Resolves to an executable for Vite.
41
+ def vite_executable
42
+ executable_exists?(path = vite_bin_path) ? [path] : %w[yarn vite]
43
+ end
44
+
45
+ # Internal: Only so that we can easily cover both paths in tests
46
+ def executable_exists?(path)
47
+ File.exist?(path)
48
+ end
49
+
50
+ # Internal: Returns a path where a Vite executable should be found.
51
+ def vite_bin_path
52
+ ENV["#{ ViteRails::ENV_PREFIX }_VITE_BIN_PATH"] || `yarn bin vite`.chomp
53
+ rescue StandardError
54
+ "#{ `npm bin`.chomp }/vite"
55
+ end
56
+ end