ruby-on-railsrubyjrubyactioncableimport-maps

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


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!


Solution

  • 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/actioncable@7.0.3-1/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/actioncable@7.0.3-1/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.