mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
We've been using basic type-checking via JSDoc for some time. This commit allows us to author proper `.ts`/`.gts` files, and use the full typescript syntax. Initially, only d-button and a single chat file are migrated, as proof of functionality. In future, we may migrate more files, and consider making our tsconfig more strict.
271 lines
7.0 KiB
Ruby
271 lines
7.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class AssetProcessor
|
|
BASE_COMPILER_VERSION = 113
|
|
|
|
PROCESSOR_DIR = "tmp/asset-processor"
|
|
LOCK_FILE = "#{PROCESSOR_DIR}/build.lock"
|
|
|
|
CACHE_DEPENDENCY_GLOBS = %w[
|
|
node_modules/.pnpm/lock.yaml
|
|
frontend/asset-processor/**/*.{js,mjs}
|
|
frontend/discourse/lib/babel-transform-module-renames.js
|
|
frontend/discourse/config/targets.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.mjs")
|
|
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.join("#{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(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
|