diff --git a/lib/reactionview/asset_manifest.rb b/lib/reactionview/asset_manifest.rb new file mode 100644 index 0000000..0ad2ef2 --- /dev/null +++ b/lib/reactionview/asset_manifest.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "json" + +module ReActionView + class AssetManifest + def initialize(assets) + @assets = assets + end + + def path + config = @assets&.config + + return unless config.respond_to?(:manifest_path) + + config.manifest_path + end + + def mtime + return if path.nil? + + File.mtime(path) + rescue SystemCallError + nil + end + + def exist? + !mtime.nil? + end + + def missing_assets + precompiled = logical_paths + + return [] if precompiled.empty? + + ReActionView::Railtie::PRECOMPILE_ASSETS - precompiled + end + + def stale? + exist? && !missing_assets.empty? + end + + def in_use? + @assets.respond_to?(:resolver) && @assets.resolver.respond_to?(:manifest_path) + end + + def explanation + <<~MESSAGE + ReActionView's dev tools assets are missing from your precompiled assets. + #{status} + + To fix this, delete the precompiled assets: + + bin/rails assets:clobber + + That is safe in development. Without public/assets/, Propshaft serves each asset straight from + the directory it lives in, including ReActionView's. + + Missing: #{missing_assets.join(", ")} + Manifest: #{path} + + public/assets/ was most likely left behind by `RAILS_ENV=production bin/rails assets:precompile`, + and a production precompile doesn't include ReActionView's dev tools. + MESSAGE + end + + private + + def status + if in_use? + "Because public/assets/.manifest.json exists, Propshaft serves every asset from public/assets/\n" \ + "and doesn't look anywhere else, which is why the lookup failed." + else + "This server booted before public/assets/.manifest.json existed, so pages still render for now.\n" \ + "After the next restart Propshaft will serve assets from public/assets/ only, and rendering\n" \ + "will fail then." + end + end + + def logical_paths + return [] if path.nil? + + JSON.parse(File.read(path)).keys + rescue SystemCallError, JSON::ParserError + [] + end + end +end diff --git a/lib/reactionview/middleware/asset_manifest_check.rb b/lib/reactionview/middleware/asset_manifest_check.rb new file mode 100644 index 0000000..56fe8ac --- /dev/null +++ b/lib/reactionview/middleware/asset_manifest_check.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative "../asset_manifest" +require_relative "../stale_asset_manifest_error" + +module ReActionView + module Middleware + class AssetManifestCheck + def initialize(app, assets: nil, logger: nil) + @app = app + @assets = assets + @logger = logger + @mutex = Mutex.new + @checked_mtime = nil + end + + def call(env) + warn_about_upcoming_failure + + @app.call(env) + rescue StandardError => e + raise unless dev_tools_asset_missing?(e) + + manifest = self.manifest + + raise unless manifest.stale? + + raise StaleAssetManifestError.for(manifest) + end + + private + + def assets + @assets || (defined?(Rails) && Rails.application&.assets) + end + + def logger + @logger || (defined?(Rails) && Rails.logger) + end + + def manifest + AssetManifest.new(assets) + end + + def warn_about_upcoming_failure + manifest = self.manifest + mtime = manifest.mtime + + return if mtime.nil? + + @mutex.synchronize do + return if @checked_mtime == mtime + + @checked_mtime = mtime + + return if manifest.in_use? || !manifest.stale? + + log("[ReActionView] #{manifest.explanation}") + end + end + + def dev_tools_asset_missing?(error) + error_class = missing_asset_error_class + + return false if error_class.nil? + + while error + if error.is_a?(error_class) && + ReActionView::Railtie::PRECOMPILE_ASSETS.any? { |asset| error.message.include?(asset) } + return true + end + + error = error.cause + end + + false + end + + def missing_asset_error_class + return unless defined?(::Propshaft::MissingAssetError) + + ::Propshaft::MissingAssetError + end + + def log(message) + if logger + logger.warn(message) + else + warn(message) + end + end + end + end +end diff --git a/lib/reactionview/railtie.rb b/lib/reactionview/railtie.rb index 6265b73..801c387 100644 --- a/lib/reactionview/railtie.rb +++ b/lib/reactionview/railtie.rb @@ -23,6 +23,14 @@ class Railtie < Rails::Railtie end end + initializer "reactionview.asset_manifest_check" do |app| + next unless ReActionView.config.development? + + require_relative "middleware/asset_manifest_check" + + app.middleware.use ReActionView::Middleware::AssetManifestCheck + end + initializer "reactionview.register_herb_handler" do ActiveSupport.on_load(:action_view) do ActionView::Template.register_template_handler :herb, ReActionView::Template::Handlers::Herb diff --git a/lib/reactionview/stale_asset_manifest_error.rb b/lib/reactionview/stale_asset_manifest_error.rb new file mode 100644 index 0000000..ab2dd25 --- /dev/null +++ b/lib/reactionview/stale_asset_manifest_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ReActionView + class StaleAssetManifestError < StandardError + def self.for(manifest) + new(manifest.explanation) + end + end +end diff --git a/test/reactionview/middleware/asset_manifest_check_test.rb b/test/reactionview/middleware/asset_manifest_check_test.rb new file mode 100644 index 0000000..6ba70c4 --- /dev/null +++ b/test/reactionview/middleware/asset_manifest_check_test.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +require "reactionview/middleware/asset_manifest_check" + +require "json" +require "tmpdir" + +unless defined?(Propshaft::MissingAssetError) + module Propshaft + class MissingAssetError < StandardError; end + end +end + +class ReActionView::Middleware::AssetManifestCheckTest < Minitest::Spec + class TestLogger + attr_reader :messages + + def initialize + @messages = [] + end + + def warn(message) + @messages << message + end + end + + StaticResolver = Struct.new(:manifest_path) + DynamicResolver = Struct.new(:load_path) + AssetsConfig = Struct.new(:manifest_path, keyword_init: true) + Assets = Struct.new(:config, :resolver, keyword_init: true) + + around do |test| + Dir.mktmpdir do |dir| + @dir = dir + test.call + end + end + + def manifest_path + File.join(@dir, ".manifest.json") + end + + def write_manifest(logical_paths) + entries = logical_paths.to_h { |path| [path, { "digested_path" => path, "integrity" => nil }] } + + File.write(manifest_path, JSON.generate(entries)) + end + + def build_assets(resolver: StaticResolver.new(manifest_path)) + Assets.new(config: AssetsConfig.new(manifest_path: manifest_path), resolver: resolver) + end + + def missing_asset_error(asset = "reactionview-dev-tools.umd.js") + Propshaft::MissingAssetError.new("The asset '#{asset}' was not found in the load path.") + end + + def wrapped(error) + raise error + rescue StandardError + begin + raise ActionView::Template::Error, "template blew up" + rescue ActionView::Template::Error => wrapper + wrapper + end + end + + def build_middleware(assets: build_assets, logger: TestLogger.new, app: ->(_env) { [200, {}, ["ok"]] }) + ReActionView::Middleware::AssetManifestCheck.new(app, assets: assets, logger: logger) + end + + describe "turning the Propshaft error into an explanation" do + test "replaces the error when the manifest is missing the dev tools assets" do + write_manifest(["application.js"]) + middleware = build_middleware(app: ->(_env) { raise missing_asset_error }) + + error = assert_raises(ReActionView::StaleAssetManifestError) { middleware.call({}) } + + assert_includes error.message, "reactionview-dev-tools.umd.js" + assert_includes error.message, "reactionview-dev-tools.esm.js" + assert_includes error.message, manifest_path + assert_includes error.message, "bin/rails assets:clobber" + end + + test "keeps the original error available as the cause" do + write_manifest(["application.js"]) + original = missing_asset_error + middleware = build_middleware(app: ->(_env) { raise original }) + + error = assert_raises(ReActionView::StaleAssetManifestError) { middleware.call({}) } + + assert_same original, error.cause + end + + test "finds the Propshaft error when ActionView wrapped it" do + write_manifest(["application.js"]) + wrapper = wrapped(missing_asset_error) + middleware = build_middleware(app: ->(_env) { raise wrapper }) + + assert_raises(ReActionView::StaleAssetManifestError) { middleware.call({}) } + end + + test "leaves the error alone when the missing asset is not ours" do + write_manifest(["application.js"]) + middleware = build_middleware(app: ->(_env) { raise missing_asset_error("some-other-asset.js") }) + + assert_raises(Propshaft::MissingAssetError) { middleware.call({}) } + end + + test "leaves the error alone when no manifest explains it" do + middleware = build_middleware(app: ->(_env) { raise missing_asset_error }) + + assert_raises(Propshaft::MissingAssetError) { middleware.call({}) } + end + + test "leaves the error alone when the manifest does contain the dev tools assets" do + write_manifest(["application.js"] + ReActionView::Railtie::PRECOMPILE_ASSETS) + middleware = build_middleware(app: ->(_env) { raise missing_asset_error }) + + assert_raises(Propshaft::MissingAssetError) { middleware.call({}) } + end + + test "leaves unrelated errors alone" do + write_manifest(["application.js"]) + middleware = build_middleware(app: ->(_env) { raise ArgumentError, "boom" }) + + assert_raises(ArgumentError) { middleware.call({}) } + end + end + + describe "warning before the manifest takes effect" do + test "warns when a stale manifest is not in use yet" do + write_manifest(["application.js"]) + logger = TestLogger.new + + build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger).call({}) + + assert_equal 1, logger.messages.size + assert_includes logger.messages.first, "booted before public/assets/.manifest.json existed" + assert_includes logger.messages.first, manifest_path + assert_includes logger.messages.first, "bin/rails assets:clobber" + end + + test "stays quiet when the manifest is already in use, since rendering raises instead" do + write_manifest(["application.js"]) + logger = TestLogger.new + + build_middleware(assets: build_assets(resolver: StaticResolver.new(manifest_path)), logger: logger).call({}) + + assert_empty logger.messages + end + + test "stays quiet when the manifest contains the dev tools assets" do + write_manifest(["application.js"] + ReActionView::Railtie::PRECOMPILE_ASSETS) + logger = TestLogger.new + + build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger).call({}) + + assert_empty logger.messages + end + + test "stays quiet when no manifest exists" do + logger = TestLogger.new + + build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger).call({}) + + assert_empty logger.messages + end + + test "stays quiet when the asset pipeline has no manifest path" do + logger = TestLogger.new + sprockets_like_assets = Struct.new(:config).new({}) + + build_middleware(assets: sprockets_like_assets, logger: logger).call({}) + + assert_empty logger.messages + end + + test "stays quiet when there is no asset pipeline" do + logger = TestLogger.new + + build_middleware(assets: nil, logger: logger).call({}) + + assert_empty logger.messages + end + + test "warns only once while the manifest is unchanged" do + write_manifest(["application.js"]) + logger = TestLogger.new + middleware = build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger) + + 3.times { middleware.call({}) } + + assert_equal 1, logger.messages.size + end + + test "warns again after the manifest changes" do + write_manifest(["application.js"]) + logger = TestLogger.new + middleware = build_middleware(assets: build_assets(resolver: DynamicResolver.new([])), logger: logger) + + middleware.call({}) + + File.utime(Time.now + 5, Time.now + 5, manifest_path) + + middleware.call({}) + + assert_equal 2, logger.messages.size + end + end + + test "passes the request through to the app" do + write_manifest(["application.js"]) + + assert_equal [200, {}, ["ok"]], build_middleware.call({}) + end +end