Home > Enterprise >  How to set up ActionCable with Importmaps in Rails 6? (MRI and Jruby)
How to set up ActionCable with Importmaps in Rails 6? (MRI and Jruby)

Time:08-15

Actioncable - an overview.

I am using jruby 9.2.16 (hence ruby 2.5.7) with rails 6.1.6.1.

I am not sure if only in development or only without ssl (wss) Actioncable can be used with the simple client side:

var ws = new WebSocket('ws://0.0.0.0:3000/channels');
ws.onmessage = function(e){ console.log(e.data); }

But at least I didn't get it running to do "Streaming from Channel" using wss in production, as it works locally (visible starting 'redis-cli' in terminal, and then 'monitor').

So I tried to implement the actioncable client scripts and hence 8 days got lost.

  1. First I struggled with the fact that there is no description which is somehow complete. Many people publish particular solutions, but this is like gambling: maybe you are lucky.

  2. Second, files are named in a way that seems to be general even though they are only about actioncable (the folder 'javascript', or 'application.js') It is misleading to not call them 'actioncable_files' and 'actioncable_app.js' and voilà there are problems because of several files with the same name.

  3. The next problem is, that lots has to be changed only, because the orderly structure of files is ignored. Javascripts no longer are in assets, but why? They could be in assets/javascripts/actioncable/ ? So manifest.js has to be changed, and even attributes have to be added in application.rb (attr_accessor :importmap). Something you find after some days in a forum.

  4. but even more confusing: importmap is a gem, which requires some directories and which somehow has to be installed (rake app:update:bin, rails importmap:install) and someone wrote, the order of the gems were relevant but you cannot only deinstall actioncable gem, because rails depends on that. Dependencies could be organized using a permutation of priority.

  5. importmaps is not working in firefox, so you need shims additionally

But in the end, everything what importmap does, looks like reinventing the wheel to me: it loads javascript files. Something, that can be done easily manually. Also importing modules is possible in simple javascript. And what else then a javascript file shall it be, that the browser finally will work with?

  1. Now importmap creates from a string '@rails/actioncable' another string 'actioncable.esm.js', which in my case (after 8 days of working 15 h to get it to work, still is not found automatically.

I cannot find that file, or any description where it is generated, or if it is only a link, or has to be compiled somehwere, but it looks to me, as if importmap is completely redundant and only makes things very complicated. I don't understand the benefit from writing a string 'xyz' which is translated in a laborious way to set up into another string 'xyz_2', which also might not be found. And if there are variables, they can be loaded directly using the same idea like action_cable_meta_tag.

Technically Actioncable is only doing what Faye did before. So why do we need all that so called "modern" way, which I think only is reinventing the wheel?

So, I would like to create a description on how to install actioncable in an easy way - without unnecessary tools and clearly.

But first I need to get it to work on my own. And therefore the question is: what shall I do due to:

GET http://0.0.0.0:3000/actioncable.esm.js net::ERR_ABORTED 404 (Not Found)

Thanks everyone for any idea!

CodePudding user response:

I've never set up ActionCable before and never used it (except second hand through Turbo::Broadcastable). Seems like you had quite the journey. I used rails guides and rails github to set it up.

First of all, from importmap-rails:

Note: In order to use JavaScript from Rails frameworks like Action Cable, Action Text, and Active Storage, you must be running Rails 7.0 . This was the first version that shipped with ESM compatible builds of these libraries. https://github.com/rails/importmap-rails#installation

Challenge accepted. I'll use mri for now (I'll try with your versions later, to see if anything weird comes up).


ActionCable

$ rails _6.1.6.1_ new cable --skip-javascript
$ cd cable

# https://github.com/rails/importmap-rails#installation

$ bin/bundle add importmap-rails
$ bin/rails importmap:install

# ActionCable guide seems rather crusty. Until this section:
# https://guides.rubyonrails.org/v6.1/action_cable_overview.html#connect-consumer
# A generator for the client side js is mentioned in the code comment.

$ bin/rails g channel chat

# Oops, generated server side rb as well.
# This should really be at the start of the guide.
# app/channels/chat_channel.rb

class ChatChannel < ApplicationCable::Channel
  def subscribed
    # NOTE: just keep it simple
    stream_from "some_channel"
  end
end

The directory app/javascript seems generic, because it is. This is for all Javascript stuff, used by shakapacker, jsbundling-rails, importmap-rails and others. I've described it a bit here: https://stackoverflow.com/a/73174481/207090

// app/javascript/channels/chat_channel.js

import consumer from "./consumer"

consumer.subscriptions.create("ChatChannel", {
  connected() {
    // NOTE: We have to check if our set up is online first,
    //       before chatting and authenticating or anything else.
    console.log("ChatChannel connected")
  },

  disconnected() {},
  received(data) {}
});

To broadcast a message, call this somewhere in your app [sic]:

ActionCable.server.broadcast("some_channel", "some message")

Ok, we have to make a controller anyway:

$ bin/rails g scaffold Message content
$ bin/rails db:migrate
$ open http://localhost:3000/messages
$ bin/rails s

Also, channels have to be imported somewhere, to load on the page. javascript_importmap_tags in the layout only imports application:
https://github.com/rails/importmap-rails#usage

<script type="module">import "application"</script>
<!--                          ^            -->
<!-- this imports the pinned `application` -->

Makes sense to import channels in application.js. Can't import ./channels/index because it has require. We'd have to use node for it to work or do something else to import all the channels. Manual way is the simplest:

// app/javascript/channels/index.js
// NOTE: it works a little differently with importmaps that I haven't mentioned yet.
//       skip this index file for now, and import channels in application.js

// app/javascript/application.js
import "./channels/chat_channel"

Browser console shows missing @rails/actioncable. Nobody told me to install it yet. Use pin command to add it:
https://github.com/rails/importmap-rails#using-npm-packages-via-javascript-cdns

$ bin/importmap pin @rails/actioncable

Refresh the browser:

ChatChannel connected                                          chat_channel:5

We got our javascript on the page. Let's make it broadcast:

# app/controllers/messages_controller.rb

# POST /messages
def create
  @message = Message.create(message_params)
  ActionCable.server.broadcast("some_channel", @message.content)
end
<!-- app/views/messages/index.html.erb -->

<div id="chat"></div> <!-- output for broadcasted messages -->

<!-- since I have no rails ujs, for my purposes: bushcraft { remote: true } -->
<!--                                                 v                      -->
<%= form_with model: Message.new, html: { onsubmit: "remote(event, this)" } do |f| %>
  <%= f.text_field :content %>
  <%= f.submit %>
<% end %>

<script type="text/javascript">
  // you can skip this, I assume you have `rails_ujs` installed or `turbo`.
  // { remote: true } or { local: false } is all you need on the form.
  function remote(e, form) {
    e.preventDefault();
    fetch(form.action, {method: form.method, body: new FormData(form)})
    form["message[content]"].value = ""
  }
</script>

We know we're connected. The form is submitting to MessagesController#create without refreshing where we're broadcasting to "some_channel". All that's left is to do is output data on the page:
https://guides.rubyonrails.org/v6.1/action_cable_overview.html#client-server-interactions-subscriptions

// app/javascript/channels/chat_channel.js

// update received() function
received(data) {
  document.querySelector("#chat")
    .insertAdjacentHTML("beforeend", `<p>${data}</p>`)
}

ActionCable done. Now let's fix importmaps.


Importmaps

Something I didn't mention before and it is super important to understand.

Everything works, but, only in development, I explained why here:
https://stackoverflow.com/a/73136675/207090

URLs and relative or absolute paths will not be mapped and more importantly will bypass the asset pipeline sprockets. To actually use importmaps, all the files in app/javascript/channels have to be mapped, aka pinned, and then referred to only by the pinned name when importing.

# config/importmap.rb

# NOTE: luckily there is a command to help with bulk pins
pin_all_from "app/javascript/channels", under: "channels"

pin "application", preload: true
pin "@rails/actioncable", to: "https://ga.jspm.io/npm:@rails/[email protected]/app/assets/javascripts/actioncable.esm.js"
# NOTE: the big reveal -> follow me ->------------------------------------------------------------------^^^^^^^^^^^^^^^^^^

# NOTE: this only works in rails 7 
# pin "@rails/actioncable", to: "actioncable.esm.js"
# `actioncable.esm.js` is in the asset pipeline so to speak and can be found here:
# https://github.com/rails/rails/tree/v7.0.3.1/actioncable/app/assets/javascripts

For some info on pin and pin_all_from:
https://stackoverflow.com/a/72855705/207090

You can see the importmaps this creates in the browser or in the terminal:

$ bin/importmap json
{
  "imports": {
     "application":           "/assets/application-3ac17ae8a9bbfcdc9571d7ffac88746f5a76b18c149fdaf02fa7ed721b3e7c49.js",
     "@rails/actioncable":    "https://ga.jspm.io/npm:@rails/[email protected]/app/assets/javascripts/actioncable.esm.js",
     "channels":              "/assets/channels/index-78e712d4a980790be34a2e859a2bd9a1121f9f3b508bd3f7de89889ff75828a0.js",
     "channels/chat_channel": "/assets/channels/chat_channel-0a2f983da2629a4d7edef5b7f05a494670df3f99ec6a22a2e2fee91a5d1c1d05.js",
     "channels/consumer":     "/assets/channels/consumer-b0ce945e7ae055dba9cceb062a47080dd9c7794a600762c19d38dbde3ba8ff0d.js"
  }#    ^                        ^
}  #    |                        |
   #  names you use             urls browser uses
   #    |   to import            ^   to actually get it
   #    |                        |
   #    `---> importmaped to ----'

For importmaps info (not the importmap-rails gem):
https://github.com/WICG/import-maps

Importmaps do not import anything, they map name to url. If you make name look like url with /name, ./name, ../name, http://js.cdn/name there is nothing to map.

import "channels/chat_channel"

// stays unchanged and is now the same as

import "/assets/channels/chat_channel-0a2f983da2629a4d7edef5b7f05a494670df3f99ec6a22a2e2fee91a5d1c1d05.js"

// because we have an importmap for "channels/chat_channel"

You don't want to use the second form with an absolute path in you js files, because the digest hash changes on file updates to invalidate the browser cache (this is handled by sprockets).

Convert all the imports:

import consumer from "./consumer"
import "./channels/chat_channel"

to match the pinned names:

import consumer from "channels/consumer"
import "channels/chat_channel"

// import "channels" // is mapped to `channels/index`
// TODO: want to auto import channels in index file?
//       just get all the pins named *_channel and import them,
//       like stumulus-loading does for controllers:
//       https://github.com/hotwired/stimulus-rails/blob/v1.1.0/app/assets/javascripts/stimulus-loading.js#L8

Jruby

Same setup on jruby. I just installed it and updated my Gemfile:

# Gemfile
ruby "2.5.7", engine: "jruby", engine_version: "9.2.16.0"
gem "activerecord-jdbcsqlite3-adapter"
gem "importmap-rails", "< 0.8" # after version 0.8.0 ruby >= 2.7 is required

First error, when starting the server:

NoMethodError: private method `importmap=' called for #<Cable::Application:0x496a31da>

importmap= method is defined here:
https://github.com/rails/importmap-rails/blob/v0.7.6/lib/importmap/engine.rb#L4

Rails::Application.send(:attr_accessor, :importmap)

In jruby it defines private methods when used this way:

>> A = Class.new
>> A.attr_accessor(:m)
>> A.new.m
NoMethodError (private method 'm' called for #<A:0x5f2f577>)

The fix is to override the definition in you app, or make those methods public:

# config/application.rb

module Cable
  class Application < Rails::Application
    # make them public
    public :importmap, :importmap=

    config.load_defaults 6.1
  end
end

That's it. No other issues. You should expect some set backs anyway, because you're using jruby which is quite behind the mri. Ruby 2.5 EOL'd on Apr 05, 2021. You can't expect latest gems to play nice with old ruby versions.

  • Related