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!
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.
// 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.