ruby-on-railsactioncable

Ruby-on-Rails app using Action-Cable runs in development but fails in production mode


My RoR app uses Action-Cable to update the page when messages are received from a GPS receiver. This works fine in development mode, but the page updates are not received by the browser in production mode. (BTW, this is running in a Docker container.)

The browser is producing a 404 error...

[Error] Error: 404 Not Found http://192.168.2.180/assets/channels/consumer — es-module-shims.min-69…1d2841985042ce58e1b94af5dc14ab8268b3d02e7de3d6.js:0

The header of the HTML page follows. The offending line marked with arrow. Note that the same list of imports is listed in development mode, too.

<head>
    <title>GpsMonitor</title>
    <meta name="csrf-param" content="authenticity_token"/>
    <meta name="csrf-token" content="aEStfQBfly16h-8ySZGPpHBc0gF9iQgSewdVOsG2NJXS8bVYhwz2BbcJ_g4atCcD41F5vzQN9EHO0nGWu--m0g"/>

    <link rel="stylesheet" href="/assets/application-3a0ff7f096b12b42db8b2f5812508637530e3bd029af4da12e48832434751882.css" data-turbo-track="reload"/>
    <script type="importmap" data-turbo-track="reload">{
      "imports": {
        "application": "/assets/application-f6263e77bd95d299ab5b103d0d21370b97b4da63a30ad5db93e56abbe721f313.js",
        "@hotwired/turbo-rails": "/assets/turbo.min-305f0d205866ac9fc3667580728220ae0c3b499e5f15df7c4daaeee4d03b5ac1.js",
        "@hotwired/stimulus": "/assets/stimulus.min-900648768bd96f3faeba359cf33c1bd01ca424ca4d2d05f36a5d8345112ae93c.js",
        "@hotwired/stimulus-loading": "/assets/stimulus-loading-685d40a0b68f785d3cdbab1c0f3575320497462e335c4a63b8de40a355d883c0.js",
        "@rails/actioncable": "/assets/actioncable.esm-3d92de0486af7257cac807acf379cea45baf450c201e71e3e84884c0e1b5ee15.js",
        "controllers/application": "/assets/controllers/application-368d98631bccbf2349e0d4f8269afb3fe9625118341966de054759d96ea86c7e.js",
        "controllers/gps_klass_counts_controller": "/assets/controllers/gps_klass_counts_controller-91f59961c65aee970b582c9981d193a0a28ff34b53cb15503723ddb553a0257b.js",
        "controllers/gps_messages_pps_controller": "/assets/controllers/gps_messages_pps_controller-3f740c9cae7ebf08fe8881fcf2bafe7fa56182e50bb78eefd41631dc96262cac.js",
        "controllers/gps_messages_tpv_controller": "/assets/controllers/gps_messages_tpv_controller-6a58e2bbee0bbcf3cff4c37c00d3e6883ed7dbc99ec3ae8631d274a69a1e56db.js",
        "controllers": "/assets/controllers/index-73a52b5f64ab23bee4b96cc21c0c816f2ee50e0b6b0f2447981812f996e8ff74.js",
        "controllers/process_message_data": "/assets/controllers/process_message_data-39a87fbb10ce21c1da968c836e18ad04ebaf03209e43a0d897e29c64bfb76d48.js",
---->>  "channels/consumer": "/assets/channels/consumer-4102819cda7b48d7adaef7ad1421a4d6d7d9f3b993e89e34bfdbdbcc12785a4c.js",
        "channels": "/assets/channels/index-76c0fcbc336063635d9bbf546313bf2fc042e3afb60ac41446a0c23578878bf5.js"
      }
    }</script>
    <link rel="modulepreload" href="/assets/application-f6263e77bd95d299ab5b103d0d21370b97b4da63a30ad5db93e56abbe721f313.js">
    <link rel="modulepreload" href="/assets/turbo.min-305f0d205866ac9fc3667580728220ae0c3b499e5f15df7c4daaeee4d03b5ac1.js">
    <link rel="modulepreload" href="/assets/stimulus.min-900648768bd96f3faeba359cf33c1bd01ca424ca4d2d05f36a5d8345112ae93c.js">
    <link rel="modulepreload" href="/assets/stimulus-loading-685d40a0b68f785d3cdbab1c0f3575320497462e335c4a63b8de40a355d883c0.js">
    <script src="/assets/es-module-shims.min-6982885c6ce151b17d1d2841985042ce58e1b94af5dc14ab8268b3d02e7de3d6.js" async="async" data-turbo-track="reload"></script>
    <script type="module">
    import "application"
    </script>
</head>

Associated importmap.rb

# Pin npm packages by running ./bin/importmap

pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@rails/actioncable", to: "actioncable.esm.js"
pin_all_from "app/javascript/channels", under: "channels"

The nginx config file

# nginx.conf

server {
    listen 80;
    server_name tick.rustyshamrock.local;
    root /home/app/tick/public;

    passenger_enabled on;
    passenger_user app;
    passenger_ruby /usr/bin/ruby;
    
    # see: https://github.com/phusion/passenger/issues/2409
    passenger_env_var RUBYOPT '-r bundler/setup';
 
    # Nginx has a default limit of 1 MB for request bodies, which also applies
    # to file uploads. The following line enables uploads of up to 50 MB:
    client_max_body_size 50M;
    
    location /cable {
        passenger_app_group_name /home/app/tick_action_cable;
        # passenger_app_group_name /home/app/tick/cable;
        passenger_force_max_concurrent_requests_per_process 0;
    }
}

app/channels/gps_klass_counts_channel.rb

# app/channels/gps_klass_counts_channel.rb

class GpsKlassCountsChannel < ApplicationCable::Channel
  def subscribed
    logger.debug ""
    logger.debug "******* File: #{__FILE__}"
    logger.debug "******* Subscribed params: #{params}"
    logger.debug "******* params[:channel]: #{params[:channel]}"
    logger.debug "******* params[:klass]: #{params[:klass]}"
    logger.debug "******* self.channel_name: #{self.channel_name}"
    stream_from "#{self.channel_name}"
    # stream_from "gps_klass_counts"
  end


  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

# ******* Subscribed params: {"channel"=>"GpsKlassCountsChannel"}
# ******* params[:channel]: GpsKlassCountsChannel
# ******* params[:klass]: 
# ******* self.channel_name: gps_klass_counts
# GpsKlassCountsChannel is transmitting the subscription confirmation
# GpsKlassCountsChannel is streaming from gps_klass_counts

app/channels/gps_messages_channel.rb

# app/channels/gps_messages_channel.rb

class GpsMessagesChannel < ApplicationCable::Channel
  def subscribed
    logger.debug ""
    logger.debug "******* File: #{__FILE__}"
    logger.debug "******* Subscribed params: #{params}"
    logger.debug "******* params[:channel]: #{params[:channel]}"
    logger.debug "******* params[:klass]: #{params[:klass]}"
    logger.debug "******* self.channel_name: #{self.channel_name}"
    if params.key?(:klass)
      stream_from "#{self.channel_name}_#{params[:klass]}"
    else
      stream_from "#{self.channel_name}"
    end
  end


  def unsubscribed
    logger.debug ""
    logger.debug "******* File: #{__FILE__}"
    logger.debug "******* Subscribed params: #{params}"
    logger.debug "******* params[:channel]: #{params[:channel]}"
    logger.debug "******* params[:klass]: #{params[:klass]}"
    logger.debug "******* self.channel_name: #{self.channel_name}"
  end
end

# ******* Subscribed params: {"channel"=>"GpsMessagesChannel"}
# ******* params[:channel]: GpsMessagesChannel
# ******* params[:klass]: 
# ******* self.channel_name: gps_messages
# GpsMessagesChannel is transmitting the subscription confirmation
# GpsMessagesChannel is streaming from gps_messages

app/channels/application_cable/channel.rb

module ApplicationCable
  class Channel < ActionCable::Channel::Base
  end
end

app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
  end
end

On the javascript side. app/javascript/application.js

// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "channels"

app/javascript/channels/consumer.js - the offending file?

// app/javascript/channels/consumer.js

// Action Cable provides the framework to deal with WebSockets in Rails.
// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command.

import { createConsumer } from "@rails/actioncable"

export default createConsumer()

app/javascript/channels/index.js

// app/javascript/channels/index.js

// Import all the channels to be used by Action Cable

// no longer used, see 'controllers'
// import "channels/gps_messages_channel"  
// import "channels/gps_klass_counts_channel"

app/javascript/controllers/application.js

import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = false
window.Stimulus   = application

export { application }

app/javascript/controllers/index.js

// Import and register all your controllers from the importmap under controllers/*

import { application } from "controllers/application"

// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
// lazyLoadControllersFrom("controllers", application)

// append to the bottom of existing file contents
import consumer from '../channels/consumer'
application.consumer = consumer

app/javascript/controllers/gps_klass_counts_controller.js (This is representative of all the other javascript/controllers

// app/javascript/controllers/gps_klass_counts_controller.js

import { Controller } from "@hotwired/stimulus"

import * as ActionCable from '@rails/actioncable'
ActionCable.logger.enabled = false;

// import { process_message_data } from '../channels/process_message_data'

function capitalize (value) {
  return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();    
}

export default class extends Controller {
  static targets = [
    'countTable',
    'countAll',
    'countPps',
    'countVersion',
    'countSent',
    'countSky',
    'countWatch',
    'countDevices',
    'countToff',
    'countGst',
    'countTpv'
  ];

  connect() {
    var that = this;
    console.log( "Subscribing to gps_klass_counts (controller)", this.element);

    this.channel = this.application.consumer.subscriptions.create({channel: "GpsKlassCountsChannel"}, {

      connected() { console.log("Connected gps_klass_counts (controller)", that.element); },

      disconnected() { console.log("Disconnected gps_klass_counts (controller)", that.element); },

      received(data) {
        console.log("Received gps_klass_counts (controller)", data);
        
        // console.log ("that:", that);  
        // console.log ("that.element:", that.element);
        // console.log ("that.hasCountTableTarget:", that.hasCountTableTarget);
        // console.log ("that.countTableTargets: ", that.countTableTargets);
        // console.log ("that.countTableTarget: ", that.countTableTarget);
        // console.log ("that.countTableTarget.innerHTML: ", that.countTableTarget.innerHTML);


        // console.log("Processing data object: ", data.html);
        // console.log("Processing Objects: ",Object.entries(data.html));
        Object.entries(data.html).forEach(([key, value]) => {
          // console.log (`Key: ${key}, Value: ${value}`);
          if (key === "ALL"){
            if (that.hasCountAllTarget) {
              that.countAllTarget.innerHTML = value;
            } else {
              console.log("Cannot find countAllTarget")
            }
          } else {
            let cmmd1 = `that.hasCount${capitalize(key)}Target`;
            // console.log(`Cmmd1 is: ${cmmd1}`);
            if (eval( cmmd1)) {
              let cmmd2 = `that.count${capitalize(key)}Target.innerHTML = value`;
              // console.log(`Cmmd2 is: ${cmmd2}`);
              eval(cmmd2);
            }else{
              console.log(`Cannot find count${capitalize(key)}Target`)
            }
          }
        })
      

        if (that.hasCountTableTarget) {
          // that.countTableTarget.innerHTML = data.html;
        } else {
          console.log ("Target Element Not Found");
        }
        console.log("Msg Counts Processing Complete");
      }
    });
  }

  disconnect () {
    console.log( "Unsubscribing from gps_klass_counts channel (controller)");
    this.channel.unsubscribe();
    console.log( "Unsubscribed from gps_klass_counts channel (controller)");
  }
  
  countTableTargetConnected( element) {
    console.log( "***** In countsTableTargetConnected!!! *****");
  }
  
  countTableTargetDisconnected( element) {
    console.log( "***** In countsTableTargetDisconnected!!! *****", element);
  }

  countAllTargetConnected( element)  {
    console.log( "countAllTargetConnected", element);    
  }

  countAllTargetDisonnected( element)  {
    console.log( "countAllTargetDisconnected", element);        
  }

}



ActionCable.logger.enabled = false;

Sorry about the long list of source files, but Action-Cable seems to be spread out all over the place. You'll note that I have embedded a bunch of debug log messages in the channels and javascript/controlers files. These get triggered immediately on startup (in development mode), signifying that the connection has been made. They don't show up at all in production mode -- which makes sense if the browser cannot locate the appropriate files.

Sure hope somebody can see what the problem is. If any more information is needed just ask.

Thanks!


Solution

  • Relative imports cannot be used with importmap-rails [1]. It skips the mapping part and all those pins are bypassed:

    // This doesn't work
    // import consumer from "../channels/consumer"
    
    // This works
    import consumer from "channels/consumer"
    
    // You even pointed to it: ---->>  "channels/consumer"
    

    import "name" or import something from "name" has to match pinned "name".

    At their core, importmaps are essentially a string substitution for what are referred to as "bare module specifiers". A "bare module specifier" looks like this: import React from "react". https://github.com/rails/importmap-rails#how-do-importmaps-work


    Why does it work in development?

    When relative or absolute path or a URL is specified,

    import consumer from "../channels/consumer"
    import React from "/Users/DHH/projects/basecamp/node_modules/react"
    import React from "https://ga.jspm.io/npm:react@17.0.1/index.js"
    

    it doesn't match any of the importmaps and that's the end of importmaps part. No mapping happens.

    Browser will then try to do the import, because browser speaks in URLs, all those specifiers are treated as URLs.

    // Works. 
    import React from "https://ga.jspm.io/npm:react@17.0.1/index.js"
    
    // This is not an absolute file path, this is an absolute URL:
    import React from "/Users/DHH/projects/basecamp/node_modules/react"
    // "http://localhost:3000/Users/DHH/projects/basecamp/node_modules/react"
    // if you don't have that route, and you shouldn't, it fails.
    
    // This is a relative URL:
    import consumer from "../channels/consumer"
    // NOTE: Because import happens in the browser,
    //       it is relative to: http://localhost:3000/assets/controllers/index-73a52b5f64ab23bee4b96cc21c0c816f2ee50e0b6b0f2447981812f996e8ff74.js
    //                          (which was successfully imported through importmaps)
    //       and resolves to:   http://localhost:3000/assets/channels/consumer
    
    // To make it even clearer:
    import consumer from "../../channels/consumer"
    // will try to import: 
    //    http://localhost:3000/channels/consumer
    // and that route doesn't exist, and will fail in development as well.
    

    Notice that there is no hash signature. When browser sends a request to http://localhost:3000/assets/channels/consumer, sprockets will look for a file channels/consumer.{js,css,...} in any of Rails.application.config.assets.paths. app/javascript is one of those paths. When it finds app/javascript/channels/consumer.js it compiles it on the fly which makes it available through /assets route and you get a successful response. But only in development.

    In production, assets are served by the web server, nginx. So you precompile them to public/assets directory and configure nginx to serve assets from that directory. Now, request comes in for /assets/channels/consumer but there is no channels/consumer file. There is only precompiled:
    channels/consumer-4102819cda7b48d7adaef7...e89e34bfdbdbcc12785a4c.js
    On the fly precompilation is turned off in production. Also, rails doesn't even receive that request, nginx does, which results in a 404.


    1. Technically you could make relative paths work, how well it would work I don't know:
      // config/importmap.rb
      pin "/assets/channels/consumer", to: "channels/consumer.js"
      
      // importing with relative url should work
      
      // app/javascript/application.js
      import "./channels/consumer"
      
      // app/javascript/channels/index.js
      import "./consumer"
      
      As far as I can tell, both of these imports will be mapped to /assets/channels/consumer and then to the correctly digested url to play nice with sprockets in development and production.