Skip to content

Commit

Permalink
CableReady 5.0 installer (#233)
Browse files Browse the repository at this point in the history
This PR is the CableReady counterpart to stimulusreflex/stimulus_reflex#610.

I'm not going to rehash everything discussed in the SR installer; it is a modified subset of the same system. There are some small differences but great pains have been taken to keep everything consistent. **It is possible to install CableReady, and then add StimulusReflex to the project and install StimulusReflex at a later date.** Note that if you try to run the CableReady installer when StimulusReflex is in your project, it will exit and tell you to run the SR installer; 

It supports all major bundlers in their default configuration:

Rails 6.1: webpacker 5.4.3, shakapacker, vite
Rails 7: importmap, esbuild, shakapacker, vite

I've adapted the `cable_ready:channel` generator to work with the new installer. The `cable_ready:initializer` generator has been removed in favor of the installer.

```bash
rails cable_ready:install # fully automatic detections, with prompts
rails cable_ready:install esbuild # specify a bundler
rails cable_ready:install entrypoint=app/frontend webpacker # install with webpacker 5.4.3 in app/frontend
rails cable_ready:install:step action_cable yarn bundle # re-run one or more steps
rails cable_ready:install:restart # wipe state and restart install

rails cable_ready:install uncomment=true # uncomment any necessary includes that have been commented
rails cable_ready:install spring=false # do not remove spring (equivalent to answering `n`)
rails cable_ready:install trace=true # debug install process with full stack trace

rails cable_ready:install:step mrujs # install mrujs
rails cable_ready:install:step compression # monkey patch Action Cable to compress WS traffic with deflate

# full fat install with zero interactive prompts, suitable for scripting
rails cable_ready:install esbuild entrypoint=app/javascript uncomment=true spring=true
```

## Why should this be added

We really need to up our installation and integration game to overcome the emotional barrier people often feel when comparing us against a built-in solution. The more we can make installing CR feel like ordering a Big Mac combo, the less water "golden path" arguments hold.

Closes #179 with massive kudos to @julianrubisch for showing me how to structure an installer.
Closes #180 with apologies to @Matt-Yorkley for the delay
Fixes #130 with a hat-tip to @julianrubisch 
Fixes #131

-----

Co-authored-by: Marco Roth <[email protected]>
Co-authored-by: Julian Rubisch <[email protected]>
  • Loading branch information
3 people authored Feb 13, 2023
1 parent 93774d1 commit a7c607e
Show file tree
Hide file tree
Showing 48 changed files with 2,006 additions and 249 deletions.
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ GEM
nokogiri (1.14.0-x86_64-linux)
racc (~> 1.4)
parallel (1.22.1)
parser (3.1.3.0)
parser (3.2.0.0)
ast (~> 2.4.1)
pry (0.14.1)
coderay (~> 1.1)
Expand Down Expand Up @@ -191,7 +191,7 @@ GEM
timeout (0.3.1)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
unicode-display_width (2.3.0)
unicode-display_width (2.4.1)
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
Expand Down
17 changes: 9 additions & 8 deletions app/jobs/cable_ready_broadcast_job.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# frozen_string_literal: true

class CableReadyBroadcastJob < (defined?(ActiveJob::Base) ? ActiveJob::Base : Object)
include CableReady::Broadcaster
queue_as :default if defined?(ActiveJob::Base)
if defined?(ActiveJob::Base)
class CableReadyBroadcastJob < ActiveJob::Base
include CableReady::Broadcaster

def perform(identifier:, operations:, model: nil)
if model.present?
cable_ready[identifier.safe_constantize].apply!(operations).broadcast_to(model)
else
cable_ready[identifier].apply!(operations).broadcast
def perform(identifier:, operations:, model: nil)
if model.present?
cable_ready[identifier.safe_constantize].apply!(operations).broadcast_to(model)
else
cable_ready[identifier].apply!(operations).broadcast
end
end
end
end
12 changes: 8 additions & 4 deletions lib/cable_ready/channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,19 @@ def broadcast_to(model, clear: true)
clients_received
end

def broadcast_later(clear: true)
def broadcast_later(clear: true, queue: nil)
raise("Action Cable must be enabled to use broadcast_later") unless defined?(ActionCable)
CableReadyBroadcastJob.perform_later(identifier: identifier, operations: operations_payload)
CableReadyBroadcastJob
.set(queue: queue ? queue.to_sym : CableReady.config.broadcast_job_queue)
.perform_later(identifier: identifier, operations: operations_payload)
reset! if clear
end

def broadcast_later_to(model, clear: true)
def broadcast_later_to(model, clear: true, queue: nil)
raise("Action Cable must be enabled to use broadcast_later_to") unless defined?(ActionCable)
CableReadyBroadcastJob.perform_later(identifier: identifier.name, operations: operations_payload, model: model)
CableReadyBroadcastJob
.set(queue: queue ? queue.to_sym : CableReady.config.broadcast_job_queue)
.perform_later(identifier: identifier.name, operations: operations_payload, model: model)
reset! if clear
end
end
Expand Down
3 changes: 2 additions & 1 deletion lib/cable_ready/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ class Config
include Observable
include Singleton

attr_accessor :on_failed_sanity_checks, :on_new_version_available, :precompile_assets
attr_accessor :on_failed_sanity_checks, :on_new_version_available, :broadcast_job_queue, :precompile_assets
attr_writer :verifier_key

def initialize
super
@operation_names = Set.new(default_operation_names)
@on_failed_sanity_checks = :exit
@on_new_version_available = :ignore
@broadcast_job_queue = :default
@precompile_assets = true
end

Expand Down
224 changes: 224 additions & 0 deletions lib/cable_ready/installer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# frozen_string_literal: true

require "cable_ready/version"

### general utilities

def fetch(step_path, file)
relative_path = step_path + file
location = template_src + relative_path

Pathname.new(location)
end

def complete_step(step)
create_file "tmp/cable_ready_installer/#{step}", verbose: false
end

def create_or_append(path, *args, &block)
FileUtils.touch(path)
append_file(path, *args, &block)
end

def current_template
ENV["LOCATION"].split("/").last.gsub(".rb", "")
end

def pack_path_missing?
return false unless pack_path.nil?
halt "#{friendly_pack_path} is missing. You need a valid application pack file to proceed."
end

def halt(message)
say "❌ #{message}", :red
create_file "tmp/cable_ready_installer/halt", verbose: false
end

def backup(path, delete: false)
if !path.exist?
yield
return
end

backup_path = Pathname.new("#{path}.bak")
old_path = path.relative_path_from(Rails.root).to_s
filename = path.to_path.split("/").last

if backup_path.exist?
if backup_path.read == path.read
path.delete if delete
yield
return
end
backup_path.delete
end

copy_file(path, backup_path, verbose: false)
path.delete if delete

yield

if path.read != backup_path.read
create_or_append(backups_path, "#{old_path}\n", verbose: false)
end
say "📦 #{old_path} backed up as #{filename}.bak"
end

def add_gem(name)
create_or_append(add_gem_list, "#{name}\n", verbose: false)
say "☑️ Added #{name} to the Gemfile"
end

def remove_gem(name)
create_or_append(remove_gem_list, "#{name}\n", verbose: false)
say "❎ Removed #{name} from Gemfile"
end

def add_package(name)
create_or_append(package_list, "#{name}\n", verbose: false)
say "☑️ Enqueued #{name} to be added to dependencies"
end

def add_dev_package(name)
create_or_append(dev_package_list, "#{name}\n", verbose: false)
say "☑️ Enqueued #{name} to be added to dev dependencies"
end

def drop_package(name)
create_or_append(drop_package_list, "#{name}\n", verbose: false)
say "❎ Enqueued #{name} to be removed from dependencies"
end

def gemfile_hash
Digest::MD5.hexdigest(gemfile_path.read)
end

### memoized values

def cr_npm_version
@cr_npm_version ||= CableReady::VERSION.gsub(".pre", "-pre")
end

def package_json
@package_json ||= Rails.root.join("package.json")
end

def entrypoint
@entrypoint ||= File.read("tmp/cable_ready_installer/entrypoint")
end

def bundler
@bundler ||= File.read("tmp/cable_ready_installer/bundler")
end

def config_path
@config_path ||= Rails.root.join(entrypoint, "config")
end

def importmap_path
@importmap_path ||= Rails.root.join("config/importmap.rb")
end

def friendly_importmap_path
@friendly_importmap_path ||= importmap_path.relative_path_from(Rails.root).to_s
end

def pack
@pack ||= pack_path.read
end

def friendly_pack_path
@friendly_pack_path ||= pack_path.relative_path_from(Rails.root).to_s
end

def pack_path
@pack_path ||= [
Rails.root.join(entrypoint, "application.js"),
Rails.root.join(entrypoint, "packs/application.js"),
Rails.root.join(entrypoint, "entrypoints/application.js")
].find(&:exist?)
end

def package_list
@package_list ||= Rails.root.join("tmp/cable_ready_installer/npm_package_list")
end

def dev_package_list
@dev_package_list ||= Rails.root.join("tmp/cable_ready_installer/npm_dev_package_list")
end

def drop_package_list
@drop_package_list ||= Rails.root.join("tmp/cable_ready_installer/drop_npm_package_list")
end

def template_src
@template_src ||= File.read("tmp/cable_ready_installer/template_src")
end

def controllers_path
@controllers_path ||= Rails.root.join(entrypoint, "controllers")
end

def gemfile_path
@gemfile_path ||= Rails.root.join("Gemfile")
end

def gemfile
@gemfile ||= gemfile_path.read
end

def prefix
# standard:disable Style/RedundantStringEscape
@prefix ||= {
"vite" => "..\/",
"webpacker" => "",
"shakapacker" => "",
"importmap" => "",
"esbuild" => ".\/"
}[bundler]
# standard:enable Style/RedundantStringEscape
end

def application_record_path
@application_record_path ||= Rails.root.join("app/models/application_record.rb")
end

def action_cable_initializer_path
@action_cable_initializer_path ||= Rails.root.join("config/initializers/action_cable.rb")
end

def action_cable_initializer_working_path
@action_cable_initializer_working_path ||= Rails.root.join(working, "action_cable.rb")
end

def development_path
@development_path ||= Rails.root.join("config/environments/development.rb")
end

def development_working_path
@development_working_path ||= Rails.root.join(working, "development.rb")
end

def backups_path
@backups_path ||= Rails.root.join("tmp/cable_ready_installer/backups")
end

def add_gem_list
@add_gem_list ||= Rails.root.join("tmp/cable_ready_installer/add_gem_list")
end

def remove_gem_list
@remove_gem_list ||= Rails.root.join("tmp/cable_ready_installer/remove_gem_list")
end

def options_path
@options_path ||= Rails.root.join("tmp/cable_ready_installer/options")
end

def options
@options ||= YAML.safe_load(File.read(options_path))
end

def working
@working ||= Rails.root.join("tmp/cable_ready_installer/working")
end
File renamed without changes.
63 changes: 51 additions & 12 deletions lib/generators/cable_ready/channel_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,71 @@ class CableReady::ChannelGenerator < Rails::Generators::NamedBase
class_option :stream_for, type: :string
class_option :stimulus, type: :boolean

def destroy_not_supported
if behavior == :revoke
puts "Sorry, we don't support destroying generated channels.\nDelete the Action Cable channel class, as well as any corresponding JavaScript classes."
exit
end
end

def check_options
raise "Can't specify --stream-from and --stream-for at the same time" if options.key?(:stream_from) && options.key?(:stream_for)
if options.key?(:stream_from) && options.key?(:stream_for)
puts "Can't specify --stream-from and --stream-for at the same time"
exit
end
end

def create_channel
generate "channel", file_name
generate "channel", file_name, "--skip"
end

def enhance_channels
@entrypoint = [
"app/javascript",
"app/frontend"
].find { |path| File.exist?(Rails.root.join(path)) } || "app/javascript"
puts "Where do JavaScript files live in your app? Our best guess is: \e[1m#{@entrypoint}\e[22m 🤔"
puts "Press enter to accept this, or type a different path."
print "> "
input = Rails.env.test? ? "tmp/app/javascript" : $stdin.gets.chomp
@entrypoint = input unless input.blank?
@js_channel = "#{@entrypoint}/channels/#{file_name}_channel.js"

if using_broadcast_to?
gsub_file "app/channels/#{file_name}_channel.rb", /# stream_from.*\n/, "stream_for #{resource}.find(params[:id])\n"
template "app/javascript/controllers/%file_name%_controller.js" if using_stimulus?
else
prepend_to_file "app/javascript/channels/#{file_name}_channel.js", "import CableReady from 'cable_ready'\n"
inject_into_file "app/javascript/channels/#{file_name}_channel.js", after: "// Called when there's incoming data on the websocket for this channel\n" do
<<-JS
if (data.cableReady) CableReady.perform(data.operations)
JS
if using_stimulus?
template("#{@entrypoint}/controllers/%file_name%_controller.js")
Rails.root.join(@js_channel).delete
else
gsub_file "app/channels/#{file_name}_channel.rb", /# stream_from.*\n/, "stream_for #{resource}.find(params[:id])\n", verbose: false
gsub_file @js_channel, /"#{resource}Channel"/, verbose: false do
<<-JS
{
channel: "#{resource}Channel",
id: 1
}
JS
end
doctor_javascript_channel_class
puts "\nDon't forget to update the id in the channel subscription: #{@js_channel}\nIt's currently set to 1; you'll want to change that to a dynamic value based on something in your DOM."
end

gsub_file "app/channels/#{file_name}_channel.rb", /# stream_from.*\n/, "stream_from \"#{identifier}\"\n"
else
gsub_file "app/channels/#{file_name}_channel.rb", /# stream_from.*\n/, "stream_from \"#{identifier}\"\n", verbose: false
doctor_javascript_channel_class
end
end

private

def doctor_javascript_channel_class
prepend_to_file @js_channel, "import CableReady from 'cable_ready'\n", verbose: false
inject_into_file @js_channel, after: "// Called when there's incoming data on the websocket for this channel\n", verbose: false do
<<-JS
if (data.cableReady) CableReady.perform(data.operations)
JS
end
end

def option_given?
options.key?(:stream_from) || options.key?(:stream_for)
end
Expand Down
Loading

0 comments on commit a7c607e

Please sign in to comment.