Skip to content

Commit

Permalink
Run multiple assets processes (#131)
Browse files Browse the repository at this point in the history
Update the `assets compile` and `assets watch` commands to fork a child process per slice and run a separate assets command per each.

Provide appropriate `--path` and `--dest` flags for each of these commands, allowing each slice's assets to be compiled separately and generated into distinct directories (`public/assets/` for the app, and `public/assets/[slice_name]/` for slices).

Support per-slice assets config by running a slice's own `config/assets.js`, if present, otherwise falling back to the app's `config/assets.js`.

Use the new `node_command` setting to invoke node when running the `config/assets.js` command.
  • Loading branch information
timriley committed Feb 8, 2024
1 parent 825e725 commit 8b06fdc
Show file tree
Hide file tree
Showing 12 changed files with 562 additions and 37 deletions.
2 changes: 2 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Lint/DuplicateBranch:
Lint/EmptyFile:
Exclude:
- 'spec/fixtures/**/*.rb'
Lint/NonLocalExitFromIterator:
Enabled: false
Naming/HeredocDelimiterNaming:
Enabled: false
Naming/MethodParameterName:
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ gem "dry-files", github: "dry-rb/dry-files", branch: "main"

gem "rack"

gem "hanami-devtools", github: "hanami/devtools", branch: "main"

group :test do
gem "pry"
end
97 changes: 91 additions & 6 deletions lib/hanami/cli/commands/app/assets/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,56 @@ module CLI
module Commands
module App
module Assets
# Base class for assets commands.
#
# Finds slices with assets present (anything in an `assets/` dir), then forks a child
# process for each slice to run the assets command (`config/assets.js`) for the slice.
#
# Prefers the slice's own `config/assets.js` if present, otherwise falls back to the
# app-level file.
#
# Passes `--path` and `--dest` arguments to this command to compile assets for the given
# slice only and save them into a dedicated directory (`public/assets/` for the app,
# `public/[slice_name]/` for slices).
#
# @see Watch
# @see Compile
#
# @since 2.1.0
# @api private
class Command < App::Command
def initialize(config: app.config.assets, system_call: SystemCall.new, **)
super()
def initialize(config: app.config.assets, system_call: SystemCall.new, **opts)
super(**opts)
@system_call = system_call
@config = config
end

# @since 2.1.0
# @api private
def call(**)
cmd, *args = cmd_with_args
slices = slices_with_assets

system_call.call(cmd, *args)
if slices.empty?
out.puts "No assets found."
return
end

slices.each do |slice|
unless assets_config(slice)
out.puts "No assets config found for #{slice}. Please create a config/assets.js."
return
end
end

pids = slices_with_assets.map { |slice| fork_child_assets_command(slice) }

Signal.trap("INT") do
pids.each do |pid|
Process.kill(sig, pid)
end
end

Process.waitall
end

private
Expand All @@ -38,8 +73,58 @@ def call(**)

# @since 2.1.0
# @api private
def cmd_with_args
[config.package_manager_run_command, "assets"]
def fork_child_assets_command(slice)
Process.fork do
cmd, *args = assets_command(slice)
system_call.call(cmd, *args, out_prefix: "[#{slice.slice_name}] ")
rescue Interrupt
# When this has been interrupted (by the Signal.trap handler in #call), catch the
# interrupt and exit cleanly, without showing the default full backtrace.
end
end

# @since 2.1.0
# @api private
def assets_command(slice)
cmd = [config.node_command, assets_config(slice).to_s, "--"]

if slice.eql?(slice.app)
cmd << "--path=app"
cmd << "--dest=public/assets"
else
cmd << "--path=#{slice.root.relative_path_from(slice.app.root)}"
cmd << "--dest=public/assets/#{slice.slice_name}"
end

cmd
end

# @since 2.1.0
# @api private
def slices_with_assets
slices = app.slices.with_nested + [app]
slices.select { |slice| slice_assets?(slice) }
end

# @since 2.1.0
# @api private
def slice_assets?(slice)
slice.root.join("assets").directory?
end

# Returns the path to the assets config (`config/assets.js`) for the given slice.
#
# Prefers a config file local to the slice, otherwise falls back to app-level config.
# Returns nil if no config can be found.
#
# @since 2.1.0
# @api private
def assets_config(slice)
config = slice.root.join("config", "assets.js")
return config if config.exist?

config = slice.app.root.join("config", "assets.js")
config if config.exist?
end

# @since 2.1.0
Expand Down
19 changes: 14 additions & 5 deletions lib/hanami/cli/commands/app/assets/compile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,31 @@ module CLI
module Commands
module App
module Assets
# Compiles assets for each slice.
#
# @since 2.1.0
# @api private
class Compile < Assets::Command
desc "Compile assets for deployments"

# @since 2.1.0
# @api private
def cmd_with_args
result = super
def initialize(config: app.config.assets, system_call: InteractiveSystemCall.new(exit_after: false), **opts)
super(config: config, system_call: system_call, **opts)
end

private

# @since 2.1.0
# @api private
def assets_command(slice)
cmd = super

if config.subresource_integrity.any?
result << "--"
result << "--sri=#{escape(config.subresource_integrity.join(','))}"
cmd << "--sri=#{escape(config.subresource_integrity.join(','))}"
end

result
cmd
end
end
end
Expand Down
12 changes: 8 additions & 4 deletions lib/hanami/cli/commands/app/assets/watch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,25 @@ module CLI
module Commands
module App
module Assets
# Watches for asset changes within each slice.
#
# @since 2.1.0
# @api private
class Watch < Assets::Command
desc "Start assets watch mode"

def initialize(config: app.config.assets, system_call: InteractiveSystemCall.new, **)
super(config: config, system_call: system_call)
# @since 2.1.0
# @api private
def initialize(config: app.config.assets, system_call: InteractiveSystemCall.new(exit_after: false), **opts)
super(config: config, system_call: system_call, **opts)
end

private

# @since 2.1.0
# @api private
def cmd_with_args
super + ["--", "--watch"]
def assets_command(slice)
super + ["--watch"]
end
end
end
Expand Down
11 changes: 6 additions & 5 deletions lib/hanami/cli/interactive_system_call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ module CLI
class InteractiveSystemCall
# @api private
# @since 2.1.0
def initialize(out: $stdout, err: $stderr)
def initialize(out: $stdout, err: $stderr, exit_after: true)
@out = out
@err = err
@exit_after = exit_after
super()
end

# @api private
# @since 2.1.0
def call(cmd, *args, env: {})
def call(cmd, *args, env: {}, out_prefix: "")
::Bundler.with_unbundled_env do
threads = []
exit_status = 0
Expand All @@ -26,14 +27,14 @@ def call(cmd, *args, env: {})
Open3.popen3(env, command(cmd, *args)) do |_stdin, stdout, stderr, wait_thr|
threads << Thread.new do
stdout.each_line do |line|
out.puts(line)
out.puts("#{out_prefix}#{line}")
end
rescue IOError # FIXME: Check if this is legit
end

threads << Thread.new do
stderr.each_line do |line|
err.puts(line)
err.puts("#{out_prefix}#{line}")
end
rescue IOError # FIXME: Check if this is legit
end
Expand All @@ -44,7 +45,7 @@ def call(cmd, *args, env: {})
end
# rubocop:enable Lint/SuppressedException

exit(exit_status.exitstatus)
exit(exit_status.exitstatus) if @exit_after
end
end

Expand Down
4 changes: 2 additions & 2 deletions spec/integration/run_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def output
let(:args) { %w[unknown command] }

it "prints help and exits with error code" do
expect(exit_code).to be(1)
expect(exit_code).to eq 1
expect(stderr).to include("Commands:")
end
end
Expand All @@ -37,7 +37,7 @@ def output
let(:args) { %w[generate action home.index --slice=foo] }

it "prints error message and exits with error code" do
expect(exit_code).to be(1)
expect(exit_code).to eq 1
expect(stderr).to include("slice `foo' is missing")
end
end
Expand Down
4 changes: 4 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# frozen_string_literal: true

require "hanami/cli"
require "pathname"

SPEC_ROOT = Pathname(File.expand_path(__dir__)).freeze

RSpec.configure do |config|
config.expect_with :rspec do |expectations|
Expand Down Expand Up @@ -61,6 +64,7 @@
config.around(app: true) do |example|
require_relative "fixtures/test/config/app" unless defined?(Test::App)
example.run
$LOADED_FEATURES.delete(SPEC_ROOT.join("fixtures/test/config/app.rb").to_s)
end
end

Expand Down
94 changes: 94 additions & 0 deletions spec/support/app_integration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# frozen_string_literal: true

require "hanami/devtools/integration/files"
require "hanami/devtools/integration/with_tmp_directory"
require "json"
require "tmpdir"
require "zeitwerk"

module RSpec
module Support
module WithTmpDirectory
private

def make_tmp_directory
Pathname(Dir.mktmpdir).tap do |dir|
(@made_tmp_dirs ||= []) << dir
end
end
end

module App
end
end
end

RSpec.shared_context "App integration" do
let(:app_modules) { %i[Test TestApp Admin Main] }
end

def autoloaders_teardown!
ObjectSpace.each_object(Zeitwerk::Loader) do |loader|
loader.unregister if loader.dirs.any? { |dir|
dir.include?("/spec/") || dir.include?(Dir.tmpdir) ||
dir.include?("/slices/") || dir.include?("/app")
}
end
end

RSpec.configure do |config|
config.include RSpec::Support::Files, :app_integration
config.include RSpec::Support::WithTmpDirectory, :app_integration
config.include RSpec::Support::App, :app_integration
config.include_context "App integration", :app, :app_integration

prepare_example = proc do
# Conditionally assign in case these have been assigned earlier for specific example groups
@load_paths ||= $LOAD_PATH.dup
@loaded_features ||= $LOADED_FEATURES.dup
end

tidy_example = proc do
autoloaders_teardown!

Hanami.instance_variable_set(:@_bundled, {})
Hanami.remove_instance_variable(:@_app) if Hanami.instance_variable_defined?(:@_app)

$LOAD_PATH.replace(@load_paths)

# Remove example-specific LOADED_FEATURES added when running each example
new_features_to_keep = ($LOADED_FEATURES - @loaded_features).tap { |feats|
feats.delete_if do |path|
path =~ %r{hanami/(setup|prepare|boot|application/container/providers)} ||
path.include?(SPEC_ROOT.to_s) ||
path.include?(Dir.tmpdir)
end
}
$LOADED_FEATURES.replace(@loaded_features + new_features_to_keep)

app_modules.each do |app_module_name|
next unless Object.const_defined?(app_module_name)

Object.const_get(app_module_name).tap do |mod|
mod.constants.each do |name|
mod.send(:remove_const, name)
end
end

Object.send(:remove_const, app_module_name)
end
end

config.before(:each, :app) { instance_eval(&prepare_example) }
config.before(:each, :app_integration) { instance_eval(&prepare_example) }
config.after(:each, :app) { instance_eval(&tidy_example) }
config.after(:each, :app_integration) { instance_eval(&tidy_example) }

config.after :all do
if instance_variable_defined?(:@made_tmp_dirs)
Array(@made_tmp_dirs).each do |dir|
FileUtils.remove_entry dir
end
end
end
end

0 comments on commit 8b06fdc

Please sign in to comment.