mirror of
https://github.com/discourse/discourse.git
synced 2026-08-01 09:08:08 -05:00
This commit introduces a new build system for plugins, which shares a large amount of code with the recently-modernized theme compiler. Plugins are compiled to native ES Modules, and loaded using native `import()` in the browser, just like themes. To achieve inter-plugin imports, each bundled plugin entrypoint implements a custom 'module federation' interface. Each export from internal plugin modules is made available as a specially-named export on the entrypoint. When modules in another plugin's namespace are imported, they are automatically rewritten to use these federated entrypoints. This change should be almost 100% backwards-compatible. There are some edge cases which will behave differently, since modules are now eagerly evaluated according to the ESM spec, instead of being lazily required via asynchronous-module-definitions (AMD). The native ESM format should also provide a performance improvement. The old AMD system involved lots of nested function calls when booting the app. Now, all the source modules are bundled up into a single ESM bundle with minimal stack depth. The build system implements a filesystem-based cache. That means that if the plugin javascript files are unchanged, they will not need to be recompiled, even after restarting the Rails server. This mimics the behaviour of the theme system. Similarly, in production builds, existing files will be automatically reused if they exist. In future, we plan to include pre-built copies of common plugins in our prebuild-asset bundles. Initially, this new compiler is disabled by default. To test it, we can set the `ROLLUP_PLUGIN_COMPILER=1` environment variable. We'll continue to test, improve and document the system before enabling it by default. --------- Co-authored-by: Jarek Radosz <jradosz@gmail.com> Co-authored-by: Chris Manson <chris@manson.ie>
240 lines
6.1 KiB
Ruby
240 lines
6.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class AssetProcessor
|
|
BASE_COMPILER_VERSION = 102
|
|
|
|
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
|
|
|
|
def self.booted?
|
|
!!@ctx
|
|
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.create_new_context
|
|
# timeout any eval that takes longer than 15 seconds
|
|
ctx = MiniRacer::Context.new(timeout: 15_000, 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
|
|
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
|
|
result
|
|
end
|
|
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
|