mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-03 03:39:41 +08:00
272 lines
7.2 KiB
Ruby
272 lines
7.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class AssetProcessor
|
|
BASE_COMPILER_VERSION = 107
|
|
|
|
PROCESSOR_DIR = "tmp/asset-processor"
|
|
LOCK_FILE = "#{PROCESSOR_DIR}/build.lock"
|
|
|
|
CACHE_DEPENDENCY_GLOBS = %w[
|
|
node_modules/.pnpm/lock.yaml
|
|
frontend/asset-processor/**/*.js
|
|
frontend/discourse/lib/babel-transform-module-renames.js
|
|
frontend/discourse/config/targets.js
|
|
frontend/discourse-plugins/transform-action-syntax.js
|
|
]
|
|
|
|
@mutex = Mutex.new
|
|
@ctx_init = Mutex.new
|
|
|
|
class TranspileError < StandardError
|
|
end
|
|
|
|
class TimeoutError < StandardError
|
|
end
|
|
|
|
def self.booted?
|
|
!!@ctx
|
|
end
|
|
|
|
def self.append_es6_deprecation(content, file_path)
|
|
pseudo_random_identifier = "deprecated_gjYVqPLMxe" # Just needs to be unique enough to avoid collisions with real code
|
|
<<~JS
|
|
#{content}
|
|
import #{pseudo_random_identifier} from "discourse/lib/deprecated";
|
|
#{pseudo_random_identifier}(
|
|
"The file '#{file_path}' uses the deprecated `.js.es6` extension. Use `.js` instead.",
|
|
{
|
|
id: "discourse.es6-extension",
|
|
url: "https://meta.discourse.org/t/398894",
|
|
}
|
|
);
|
|
JS
|
|
end
|
|
|
|
def self.transpile(data, root_path, logical_path, theme_id: nil, extension: nil)
|
|
processor = new(skip_module: skip_module?(data))
|
|
processor.perform(data, root_path, logical_path, theme_id: theme_id, extension: extension)
|
|
end
|
|
|
|
def self.skip_module?(data)
|
|
!!(data.present? && data =~ %r{^// discourse-skip-module$})
|
|
end
|
|
|
|
def self.mutex
|
|
@mutex
|
|
end
|
|
|
|
def self.build_asset_processor
|
|
Discourse::Utils.execute_command("pnpm", "-C=frontend/asset-processor", "node", "build.js")
|
|
end
|
|
|
|
def self.inputs_digest
|
|
digest = Digest::MD5.new
|
|
|
|
CACHE_DEPENDENCY_GLOBS.each do |pattern|
|
|
files = Dir.glob(pattern).sort
|
|
raise "No files matched #{pattern}" if files.empty?
|
|
|
|
files.each do |file|
|
|
digest.update(file)
|
|
digest.update(File.read(file))
|
|
end
|
|
end
|
|
|
|
digest.hexdigest.to_i(16).to_s(36) # base36
|
|
end
|
|
|
|
def self.processor_file_path
|
|
"#{PROCESSOR_DIR}/asset-processor-#{inputs_digest}.js"
|
|
end
|
|
|
|
def self.with_file_lock(&block)
|
|
lock_path = "#{Rails.root}/#{LOCK_FILE}"
|
|
FileUtils.mkdir_p(File.dirname(lock_path))
|
|
File.open(lock_path, File::CREAT | File::RDWR) do |lock_file|
|
|
lock_file.flock(File::LOCK_EX)
|
|
yield
|
|
end
|
|
end
|
|
|
|
def self.cleanup_old_cache_files
|
|
Dir
|
|
.glob("#{PROCESSOR_DIR}/asset-processor-*.js")
|
|
.reject { it.end_with?(processor_file_path) }
|
|
.each { File.delete(it) }
|
|
end
|
|
|
|
def self.load_or_build_processor_source
|
|
cache_path = processor_file_path
|
|
|
|
if File.exist?(cache_path)
|
|
File.read(cache_path)
|
|
else
|
|
with_file_lock do
|
|
if File.exist?(cache_path)
|
|
File.read(cache_path)
|
|
else
|
|
built_source = build_asset_processor
|
|
FileUtils.mkdir_p(PROCESSOR_DIR)
|
|
File.write(cache_path, built_source)
|
|
cleanup_old_cache_files
|
|
built_source
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.timeout
|
|
@timeout ||= 15_000
|
|
end
|
|
|
|
def self.timeout=(value)
|
|
@timeout = value
|
|
reset_context
|
|
end
|
|
|
|
def self.create_new_context
|
|
# timeout any eval that takes longer than 15 seconds
|
|
ctx = MiniRacer::Context.new(timeout: timeout, ensure_gc_after_idle: 2000)
|
|
|
|
# General shims
|
|
ctx.attach(
|
|
"rails.logger.info",
|
|
proc do |err|
|
|
Rails.logger.info(err.to_s)
|
|
nil
|
|
end,
|
|
)
|
|
ctx.attach(
|
|
"rails.logger.warn",
|
|
proc do |err|
|
|
Rails.logger.warn(err.to_s)
|
|
nil
|
|
end,
|
|
)
|
|
ctx.attach(
|
|
"rails.logger.error",
|
|
proc do |err|
|
|
Rails.logger.error(err.to_s)
|
|
nil
|
|
end,
|
|
)
|
|
|
|
source = load_or_build_processor_source
|
|
|
|
ctx.eval("globalThis.ROLLUP_PLUGIN_COMPILER = #{ENV["ROLLUP_PLUGIN_COMPILER"].to_json}")
|
|
ctx.eval(source, filename: "asset-processor.js")
|
|
ctx.low_memory_notification # GC to free up memory used during init
|
|
|
|
ctx
|
|
end
|
|
|
|
def self.reset_context
|
|
@ctx&.dispose
|
|
@ctx = nil
|
|
end
|
|
|
|
def self.v8
|
|
return @ctx if @ctx
|
|
|
|
# ensure we only init one of these
|
|
@ctx_init.synchronize do
|
|
return @ctx if @ctx
|
|
@ctx = create_new_context
|
|
end
|
|
|
|
@ctx
|
|
end
|
|
|
|
# Call a method in the global scope of the v8 context.
|
|
# The `fetch_result_call` kwarg provides a workaround for the lack of mini_racer async
|
|
# result support. The first call can perform some async operation, and then `fetch_result_call`
|
|
# will be called to fetch the result.
|
|
def self.v8_call(*args, **kwargs)
|
|
fetch_result_call = kwargs.delete(:fetch_result_call)
|
|
mutex.synchronize do
|
|
result = v8.call(*args, **kwargs)
|
|
result = v8.call(fetch_result_call) if fetch_result_call
|
|
v8.low_memory_notification if GlobalSetting.mini_racer_single_threaded
|
|
result
|
|
end
|
|
rescue MiniRacer::ScriptTerminatedError => e
|
|
timeout_error = TimeoutError.new("Script terminated: timeout after #{timeout / 1000}s")
|
|
timeout_error.set_backtrace(e.backtrace)
|
|
raise timeout_error
|
|
rescue MiniRacer::RuntimeError => e
|
|
message = e.message
|
|
begin
|
|
# Workaround for https://github.com/rubyjs/mini_racer/issues/262
|
|
possible_encoded_message = message.delete_prefix("Error: ")
|
|
decoded = JSON.parse("{\"value\": #{possible_encoded_message}}")["value"]
|
|
message = "Error: #{decoded}"
|
|
rescue JSON::ParserError
|
|
message = e.message
|
|
end
|
|
transpile_error = TranspileError.new(message)
|
|
transpile_error.set_backtrace(e.backtrace)
|
|
raise transpile_error
|
|
end
|
|
|
|
def self.ember_version
|
|
v8_call("emberVersion")
|
|
end
|
|
|
|
def initialize(skip_module: false)
|
|
@skip_module = skip_module
|
|
end
|
|
|
|
def perform(
|
|
source,
|
|
root_path = nil,
|
|
logical_path = nil,
|
|
theme_id: nil,
|
|
extension: nil,
|
|
generate_map: false
|
|
)
|
|
self.class.v8_call(
|
|
"transpile",
|
|
source,
|
|
{
|
|
skipModule: @skip_module,
|
|
moduleId: module_name(root_path, logical_path),
|
|
filename: logical_path || "unknown",
|
|
extension: extension,
|
|
themeId: theme_id,
|
|
generateMap: generate_map,
|
|
},
|
|
)
|
|
end
|
|
|
|
def module_name(root_path, logical_path)
|
|
path = nil
|
|
|
|
root_base = File.basename(Rails.root)
|
|
# If the resource is a plugin, use the plugin name as a prefix
|
|
if root_path =~ %r{(.*/#{root_base}/plugins/[^/]+)/}
|
|
plugin_path = "#{Regexp.last_match[1]}/plugin.rb"
|
|
|
|
plugin = Discourse.plugins.find { |p| p.path == plugin_path }
|
|
path = "discourse/plugins/#{plugin.name}/#{logical_path.sub(%r{javascripts/}, "")}" if plugin
|
|
end
|
|
|
|
# We need to strip the app subdirectory to replicate how ember-cli works.
|
|
path || logical_path&.gsub("app/", "")&.gsub("addon/", "")&.gsub("admin/addon", "admin")
|
|
end
|
|
|
|
def compile_raw_template(source, theme_id: nil)
|
|
self.class.v8_call("compileRawTemplate", source, theme_id)
|
|
end
|
|
|
|
def terser(tree, opts)
|
|
self.class.v8_call("minify", tree, opts, fetch_result_call: "getMinifyResult")
|
|
end
|
|
|
|
def rollup(tree, opts)
|
|
self.class.v8_call("rollup", tree, opts, fetch_result_call: "getRollupResult")
|
|
end
|
|
|
|
def post_css(css:, map:, source_map_file:)
|
|
self.class.v8_call("postCss", css, map, source_map_file, fetch_result_call: "getPostCssResult")
|
|
end
|
|
end
|