0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/lib/ember_cli.rb
David Taylor 9527868295
DEV: Replace JS build system with Rolldown (#35963)
This replaces the old ember-cli build with a modern Rolldown build. In
local testing, this provides an 80% improvement in build times, while
remaining 100% backwards compatible for themes and plugins.

As part of this move, we have decided to stop using a proxy in front of
Discourse for development. Development should now be done directly
against the Rails server.

`bin/ember-cli -u` has been replaced with `bin/dev`. This will launch
Rails on `:3000`, and will run the rolldown build in the background. Log
output from both processes will be shown with an appropriate prefix. You
should visit `:3000` in your browser. `:4200` will no longer serve
anything.

To help with migration, `bin/ember-cli` is now a backwards-compatible
shim. It will print help information, and will launch a lightweight
server on `:4200` with instructions to move to `:3000`.

If you prefer to launch Rails and the JS build as separate commands, you
can still do that. Rails boot commands are unchanged, and the rolldown
development builder can be run using `bin/dev --only ember`.

https://meta.discourse.org/t/403908

---------

Co-authored-by: Jarek Radosz <jarek@cvx.dev>
Co-authored-by: Chris Manson <chris@manson.ie>
2026-05-29 11:11:55 +01:00

176 lines
5 KiB
Ruby
Vendored

# frozen_string_literal: true
class EmberCli < ActiveSupport::CurrentAttributes
# Cache which persists for the duration of a request
attribute :request_cache
class BuildError < StandardError
attr_reader :details
def initialize(details)
@details = details
super(details.dig("error", "message") || "Frontend build failed")
end
end
def self.dist_dir
"#{Rails.root.join("frontend/discourse/dist")}"
end
def self.assets
raise_if_build_error!
cache[:assets] ||= Dir.glob("**/*.{js,map,txt,css}", base: "#{dist_dir}/assets")
end
def self.script_chunks(exception: true)
raise_if_build_error! if exception
return cache[:script_chunks] if cache[:script_chunks]
entrypoints = {}
manifest = read_manifest!(exception: exception)
return {} if manifest.nil?
{
**manifest["entrypoints"],
**manifest["dynamicEntrypoints"],
}.each do |entry_name, entry_filename|
entrypoints[entry_name.delete_suffix(".js")] = deep_imports_for(
chunk_filename: entry_filename,
chunks: manifest["chunks"],
).map { it.delete_prefix("assets/").delete_suffix(".js") }
end
cache[:script_chunks] = entrypoints
end
def self.deep_imports_for(chunk_filename:, chunks:, seen: Set.new)
return [] unless seen.add?(chunk_filename)
[
chunk_filename,
*chunks
.dig(chunk_filename, "imports")
&.flat_map { |import| deep_imports_for(chunk_filename: import, chunks:, seen:) },
]
end
BUILD_WAIT_TIMEOUT = 20.0
BUILD_POLL_INTERVAL = 0.05
def self.build_error
return nil unless Rails.env.local?
return cache[:build_error] if cache.key?(:build_error)
cache[:build_error] = wait_for_build
end
# Polls dist/manifest/build.json until the rolldown server reports a final
# state (ok/error/crashed), the process dies, or we hit BUILD_WAIT_TIMEOUT.
# Returns nil when everything is fine; returns an error payload otherwise.
def self.wait_for_build
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BUILD_WAIT_TIMEOUT
loop do
data = read_build_status
return nil if data.nil?
if data["pid"] && !pid_alive?(data["pid"])
return data.merge("status" => "crashed", "error" => plain_error(<<~MSG))
Rolldown process (pid #{data["pid"]}) is no longer running.
Start it with `pnpm --dir frontend/discourse start`.
MSG
end
case data["status"]
when "error", "crashed"
return data
when "building"
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
return(
data.merge(
"status" => "crashed",
"error" =>
plain_error("Frontend build did not complete within #{BUILD_WAIT_TIMEOUT.to_i}s."),
)
)
end
sleep BUILD_POLL_INTERVAL
else
return nil
end
end
end
def self.read_build_status
data = JSON.parse(File.read("#{dist_dir}/manifest/build.json"))
data.is_a?(Hash) ? data : nil
rescue Errno::ENOENT, JSON::ParserError
nil
end
def self.pid_alive?(pid)
Process.kill(0, pid.to_i)
true
rescue Errno::ESRCH, Errno::EPERM, ArgumentError, RangeError
false
end
def self.raise_if_build_error!
if (details = build_error)
raise BuildError.new(details)
end
end
# Reads the rolldown manifest. Returns the parsed hash, or nil when the
# caller has opted out of raising. When `exception:` is true and the manifest
# is missing, raises BuildError so the dev error page is shown.
def self.read_manifest!(exception:)
JSON.parse(File.read("#{dist_dir}/manifest/manifest.json"))
rescue Errno::ENOENT
if exception && Rails.env.local?
raise BuildError.new("status" => "missing", "error" => plain_error(<<~MSG))
No frontend build found at #{dist_dir}/manifest/manifest.json.
Start the dev server with `pnpm --dir frontend/discourse start`.
MSG
end
nil
end
# Build an error payload from plain text, populating both `message` (used in
# the raised exception's message) and `messageHtml` (rendered in the dev
# error view, HTML-escaped here since there are no ANSI codes to convert).
def self.plain_error(text)
{ "message" => text, "messageHtml" => ERB::Util.html_escape(text) }
end
def self.is_ember_cli_asset?(name)
assets.include?(name) || script_chunks.values.flatten.include?(name.delete_suffix(".js"))
end
def self.has_tests?
script_chunks["test-entrypoint"].present?
end
def self.cache
if Rails.env.development?
self.request_cache ||= {}
else
@production_cache ||= {}
end
end
def self.clear_cache!
self.request_cache = nil
@production_cache = nil
end
def self.watch!
FileUtils.mkdir_p("#{dist_dir}/manifest")
Listen
.to("#{dist_dir}/manifest") do |modified, added, removed|
next if modified.size == 0 && added.size == 0
next if read_build_status["status"] == "building"
MessageBus.publish("/file-change", ["refresh"])
end
.start
end
end