discourse/lib/plugin/js_manager.rb
David Taylor 93a624d681
DEV: Increase plugin_manager timout, and improve error message (#38874)
Plugin code is trusted. If it takes a long time to build on low-resource
machines, so be it.
2026-03-25 15:43:07 +00:00

228 lines
6.9 KiB
Ruby

# frozen_string_literal: true
module Plugin
class JsManager
@cache = {}
def self.js_asset_exists?(plugin_directory_name)
maybe_cache("js_asset_exists_#{plugin_directory_name}") do
has_source_files_in_dir(plugin_directory_name, "assets/javascripts")
end
end
def self.admin_js_asset_exists?(plugin_directory_name)
maybe_cache("admin_js_asset_exists_#{plugin_directory_name}") do
has_source_files_in_dir(plugin_directory_name, "admin/assets/javascripts")
end
end
def self.test_js_asset_exists?(plugin_directory_name)
maybe_cache("test_js_asset_exists_#{plugin_directory_name}") do
has_source_files_in_dir(plugin_directory_name, "test/javascripts")
end
end
def self.read_manifest(plugin_directory_name)
maybe_cache("manifest_#{plugin_directory_name}") do
manifest_path = "#{Rails.root}/app/assets/generated/#{plugin_directory_name}/manifest.json"
JSON.parse(File.read(manifest_path))
rescue Errno::ENOENT
{}
end
end
def self.digested_logical_path_for(plugin_directory_name, entrypoint_name)
manifest_entry = read_manifest(plugin_directory_name)[entrypoint_name]
"js/plugins/#{manifest_entry["fileName"].delete_suffix(".js")}" if manifest_entry
end
def self.import_paths_for(plugin_directory_name, entrypoint_name)
read_manifest(plugin_directory_name)[entrypoint_name]["imports"].map do
"js/plugins/#{it.delete_suffix(".js")}"
end
end
def compile!
log "Compiling #{Discourse.plugins.count} plugins..."
start = Time.now
if !GlobalSetting.mini_racer_single_threaded && AssetProcessor.booted?
raise "[Plugin::JSManager] Cannot fork for parallel compilation because AssetProcessor is already booted."
end
parallel_count = [Etc.nprocessors, 4].min
AssetProcessor.timeout = 120_000
Parallel.each(Discourse.plugins, in_processes: parallel_count) do |plugin|
compile_js_bundle(plugin)
end
log "Finished initial compilation of plugins in #{(Time.now - start).round(2)}s"
end
def compile_js_bundle(plugin)
base_output_dir = "#{Rails.root}/app/assets/generated/#{plugin.directory_name}"
js_dir = "#{base_output_dir}/js/plugins"
map_dir = "#{base_output_dir}/map/plugins"
entrypoints = { "main" => "assets/javascripts", "admin" => "admin/assets/javascripts" }
entrypoints["test"] = "test/javascripts" if Rails.env.local?
tree = {}
entrypoints_config = {}
entrypoints.each do |name, js_path|
js_base = "#{plugin.directory}/#{js_path}"
files = Dir.glob("**/*", base: js_base)
next if files.empty?
entrypoints_config[name] = { modules: [] }
files.sort.each do |file|
full_path = File.join(js_base, file)
if File.file?(full_path)
normalized_file_path = file.sub(/\.js\.es6$/, ".js")
content = File.read(full_path)
content = AssetProcessor.append_es6_deprecation(content, file) if file.end_with?(
".js.es6",
)
tree[normalized_file_path] = content
if name == "test" && file.match(%r{/(acceptance|integration|unit)/})
if file.match?(/-test\.g?js$/)
entrypoints_config[name][:modules] << normalized_file_path
end
else
entrypoints_config[name][:modules] << normalized_file_path
end
end
end
end
hex_digest =
Digest::SHA1.hexdigest(
[
*tree.keys,
*tree.values,
AssetProcessor::BASE_COMPILER_VERSION,
AssetProcessor.ember_version,
minify?.to_s,
plugin.name,
].join,
)
base36_digest = hex_digest.to_i(16).to_s(36).first(8)
filename_prefix = "#{plugin.directory_name}_"
filename_suffix = "-#{base36_digest}.digested"
expected_entrypoints =
entrypoints_config.keys.map do |name|
"#{js_dir}/#{filename_prefix}#{name}#{filename_suffix}.js"
end
files_exist = expected_entrypoints.all? { |path| File.exist?(path) }
if !cache? || !files_exist
compiler =
Plugin::JsCompiler.new(
plugin.name,
minify: minify?,
tree: tree,
entrypoints: entrypoints_config,
filename_prefix:,
filename_suffix:,
)
result = compiler.compile!
FileUtils.mkdir_p(js_dir)
FileUtils.mkdir_p(map_dir)
manifest = {}
result.each do |file_name, info|
code = info["code"]
code += "\n//# sourceMappingURL=../../map/plugins/#{file_name}.map\n" if info["map"]
File.write("#{js_dir}/#{file_name}", code)
File.write("#{map_dir}/#{file_name}.map", info["map"]) if info["map"]
if info["isEntry"]
manifest[info["name"]] = { fileName: file_name, imports: info["imports"] }
end
end
File.write("#{base_output_dir}/manifest.json", JSON.pretty_generate(manifest))
end
# Delete any old versions
Dir
.glob("#{base_output_dir}/*/*/*")
.reject { |path| path.include?(filename_suffix) || path.include?("_extra") }
.each { |path| FileUtils.rm_rf(path) }
end
def watch
listener =
Listen.to(
*Discourse.plugins.map(&:directory),
{ ignore: [%r{/node_modules/}], only: /\.(gjs|js|hbs)\z/ },
) do |modified, added, removed|
changed_files = modified + added + removed
changed_plugins = Set.new
log "Changed files:"
changed_files.each do |file|
relative_path = Pathname.new(file).relative_path_from(Rails.root)
log "- #{relative_path}"
plugin = Discourse.plugins.find { |p| file.start_with?(p.resolved_dir) }
changed_plugins << plugin if plugin
end
log "Recompiling..."
start = Time.now
changed_plugins.each { |plugin| compile_js_bundle(plugin) }
log "Finished recompilation in #{(Time.now - start).round(2)}s"
MessageBus.publish("/file-change", ["refresh"])
rescue => e
log "Plugin JS watcher crashed \n#{e}"
end
begin
listener.start
compile!
yield
ensure
listener.stop
end
end
private
def minify?
Rails.env.production?
end
def cache?
true
end
def log(message)
STDERR.puts "[Plugin::JsManager] #{message}"
end
private_class_method def self.maybe_cache(key, &blk)
if Rails.env.production?
@cache.fetch(key, &blk)
else
blk.call
end
end
private_class_method def self.has_source_files_in_dir(plugin_directory_name, dir)
Dir.glob("plugins/#{plugin_directory_name}/#{dir}/**/*.{js,hbs,gjs,es6}") { break true } ||
false
end
end
end