Rails is cool. But modern web needs Loco-motive.
Loco-Rails is a Rails engine from the technical point of view. Conceptually, it is a framework that works on top of Rails and consists of 2 parts: front-end and back-end. They are called Loco-JS and Loco-Rails, respectively.
This is how it can be visualized:
Loco Framework
|
|--- Loco-Rails (back-end part)
| |
| |--- Loco-Rails-Core (logical structure for JS / can be used separately with Loco-JS-Core)
|
|--- Loco-JS (front-end part)
|
|--- Loco-JS-Core (logical structure for JS / can be used separately)
|
|--- Loco-JS-Model (model part / can be used separately)
|
|--- other built-in parts of Loco-JS
Loco-JS-UI - connects models with UI elements (a separate library)
The following sections contain a more detailed description of its internals and API.
- by providing a logical structure for a JavaScript code along with a base class for controllers. You exactly know where to start looking for a JavaScript code that runs a current page (Loco-JS-Core)
- you have models that protect API endpoints from sending invalid data. They also facilitate fetching objects of a given type from the server (Loco-JS-Model)
- you can easily assign a model to a form enriching this form with fields' validation (Loco-JS-UI)
- you can subscribe to a model or a collection of models on the front-end by passing a function. Front-end and back-end models can be connected. This function is called when a notification for a given model is sent on the server-side. (Loco)
- it allows sending messages over WebSockets in both directions with just a single line of code on each side (Loco)
- it respects permissions. You can filter out sent messages if a sender is not signed in as a given resource, for example, a given admin or user) (Loco)
Let's assume, 2 users are navigating to a chat room page containing a list of chat members. This is a regular request-response application without technics like AJAX polling and WebSockets.
User A | User B |
---|---|
is joining a chat | --- |
--- | is joining a chat and is seeing User A who joined before |
is not seeing User B on the list of chat members | is seeing User A and User B as chat members |
is refreshing a page | is seeing User A and User B as chat members |
is seeing User A and User B as chat members | is seeing User A and User B as chat members |
So, you have to constantly refresh a page to get the current list of chat members. Or you need to provide a "live" functionality through AJAX or WebSockets. This requires a lot of unnecessary work/code for every element of your app like this. It should be much easier. And by easier, I mean ~1 significant line of code on the back-end and front-end side. Look for the Loco.emit
method on the back-end and subscribe
function on the front-end.
# app/controllers/user/rooms_controller.rb
class User
class RoomsController < UserController
def join
@hub.add_member current_user
Loco.emit @room, :member_joined, payload: {
room_id: @room.id,
member: {
id: current_user.id,
username: current_user.username
}
}
redirect_to user_room_url(@room)
end
end
end
Below is how the front-end version of Room
model can look like. If they share the same name, you can consider them as "connected". Otherwise, you need to specify the mapping. For all the options, look at the Loco-JS-Model documentation.
// frontend/js/models/Room.js
import { Models } from "loco-js";
class Room extends Models.Base {
static identity = "Room";
constructor(data) {
super(data);
}
}
export default Room;
Below is an example of a view that always renders an up-to-date list of chat members.
// frontend/js/views/user/rooms/Show.js
import { subscribe } from "loco-js";
import Room from "models/Room";
const memberJoined = member => {
const li = `<li id='user_${member.id}'>${member.username}</li>`;
document.getElementById("members").insertAdjacentHTML("beforeend", li);
};
const createReceivedMessage = roomId => {
return function(type, data) {
switch (type) {
case "Room member_joined":
if (data.room_id !== roomId) return;
memberJoined(data.member);
break;
}
};
};
export default {
render: roomId => {
subscribe({ to: Room, with: createReceivedMessage(roomId) });
},
renderMembers: members => {
for (const member of members) {
memberJoined(member);
}
}
};
This is just the tip of the iceberg. Look at Loco-JS and Loco-JS-Model documentation for more.
Loco-JS
- ๐ no strict external dependencies. ๐ But check out its "soft dependencies"โ๏ธ
Loco-Rails
- Loco-Rails-Core - Rails plugin that has been extracted from Loco-Rails so it could be used as a stand-alone lib. It provides a logical structure for JavaScript code that corresponds with Rails` controllers and their actions that handle a given request. Loco-Rails-Core requires Loco-JS-Core to work.
- modern Ruby (tested on >= 2.3.0)
- Rails 5
- Redis and redis gem - Loco-Rails stores information about WebSocket connections in Redis. It is not required if you don't want to use ActionCable.
To have Loco fully functional, you have to install both: back-end and front-end parts.
1๏ธโฃ Loco-Rails works with Rails 5 onwards. You can add it to your Gemfile with:
gem 'loco-rails'
At the command prompt run:
$ bundle install
$ bin/rails generate loco:install
$ bin/rails db:migrate
2๏ธโฃ Now it's time for the front-end part. Install it using npm (or yarn):
$ npm install loco-js --save
Familiarize yourself with the proper sections from the Loco-JS documentation on how to set up everything on the front-end side.
Look inside test/dummy/
to check a recommended setup with the webpack.
Loco-Rails and Loco-JS both use Semantic Versioning (MAJOR.MINOR.PATCH). It is required to keep the MAJOR version number the same between Loco-Rails and Loco-JS to maintain compatibility.
Some features may require an upgrade of MINOR version both for front-end and back-end parts. Check Changelogs and follow our Twitter to be notified.
1๏ธโฃ loco:install
generator creates config/initializers/loco.rb
file (among other things) that holds configuration:
# frozen_string_literal: true
Loco.configure do |c|
c.silence_logger = false # false by default
c.notifications_size = 10 # 100 by default
c.app_name = "loco_#{Rails.env}" # your app's name (required for namespacing)
end
Where:
- notifications_size - max number of notifications returned from the server at once
- app_name - used as key's prefix to store info about current WebSocket connections in Redis
2๏ธโฃ Browse all generated files and customize them according to the comments.
Use Loco.emit
or Loco.emit_to
module functions to send different types of messages.
This module function emits a notification that informs recipients about an event that occurred on the given resource - e.g., the post was updated, the ticket was validated. If a WebSocket connection is established - a message is sent this way. If not - it's delivered via AJAX polling. Switching between an available method is done automatically.
Notifications are stored in the loco_notifications table in the database. One of the advantages of saving messages in a DB is that when the client loses connection with the server and restores it after a certain time - he will get all not received notifications ๐ unless you delete them before, of course.
Example:
receivers = [article.user, Admin, 'a54e1ef01cb9']
data = { foo: 'bar' }
Loco.emit(article, :confirmed, to: receivers, payload: data)
Arguments:
- a resource this event relates to
- a name of an event that occurred (Symbol/String). Default values are:
- :created - when
created_at == updated_at
- :updated - when
updated_at > created_at
- :created - when
- a hash with relevant keys:
- :to - message's recipients. It can be a single object or an array of objects. Instances of models, their classes, and strings are accepted. If a recipient is a class, then given notification is addressed to all instances of this class currently signed in. If a receiver is a string (token), clients will receive notifications who have subscribed to this token on the front-end side. They can do this by invoking this code:
getWire().token = "<token>";
- :data - additional data, serialized to JSON, transmitted along with the notification
- :to - message's recipients. It can be a single object or an array of objects. Instances of models, their classes, and strings are accepted. If a recipient is a class, then given notification is addressed to all instances of this class currently signed in. If a receiver is a string (token), clients will receive notifications who have subscribed to this token on the front-end side. They can do this by invoking this code:
When you emit a lot of notifications, you create a lot of records in the database. This way, your loco_notifications table may soon become very big. You must periodically delete old records. Below is a somewhat naive approach, but it works.
# frozen_string_literal: true
class GarbageCollectorJob < ApplicationJob
queue_as :default
after_perform do |job|
GarbageCollectorJob.set(wait_until: 1.hour.from_now).perform_later
end
def perform
Loco::Notification.where('created_at < ?', 1.hour.ago)
.find_each(&:destroy)
end
end
This module function emits a direct message to recipients. Direct messages are sent only via a WebSocket connection and are not persisted in a DB.
ApplicationCable::Channel
s. Remember that Loco places ActiveJob
s into the :loco
queue.
If you want to send a message to a group of recipients, persist this group, and have an ability to add/remove members - an entity called Communication Hub may be handy.
You can treat it like a virtual room where you can add/remove members.
It works over WebSockets only with the emit_to
module function.
Loco
also provides hub management module functions such as add_hub
, get_hub
, del_hub
.
Details:
-
add_hub(name, members = [])
- creates and returns an instance ofLoco::Hub
with a given name and members passed as a 2nd argument. In a typical use case - members should be an array of ActiveRecord instances. -
get_hub(name)
- returns an instance ofLoco::Hub
with a given name ornil
if a hub does not exist. -
del_hub(name)
- destroys an instance ofLoco::Hub
with a given name if it exists.
Important instance methods of Loco::Hub
:
name
members
- returns the hub's members. Members are stored in an informative, shortened form inside Redis. Be aware that this method performs calls to DB to fetch all members.raw_members
- returns hub's members in the shortened form as they are stored:"{class}:{id}"
add_member(member)
del_member(member)
include?(member)
destroy
Example:
hub1 = Hub.get('room_1')
admin = Admin.find(1)
data = { type: 'NEW_MESSAGE', message: 'Hi all!', author: 'system' }
Loco.emit_to([hub1, admin], data)
Arguments:
- recipients - a single object or an array of objects. ActiveRecord instances and Communication Hubs are allowed.
- data - a hash serialized to JSON during sending.
You can send messages over a WebSocket connection from the browser to the server using the emit
function. These messages can be received on the back-end by the Loco::NotificationCenter
class located in app/services/loco/notification_center.rb
loco:install
generator generates this class.
The received_message
instance method is called automatically for each message sent by front-end clients. 2 arguments are passed:
-
a hash with resources that can sign in to your app. You define them as
loco_permissions
insideApplicationCable::Connection
class. The keys of this hash are lowercase class names of signed-in resources, and the values are the instances themselves. -
a hash with sent data
You can look at the working example here.
$ bundle install
$ docker-compose up
$ bin/rails db:create
$ bin/rails test
Capybara powers integration tests. Capybara is cool, but sometimes random tests fail unexpectedly. So before you assume that something is wrong, just run failed tests separately. It helps to keep the focus on the browser's window that runs integration tests on macOS.
- all
Loco::Emitter
methods are available asLoco
'smodule_function
s - Deprecation warning:
Loco::Emitter
will be removed in Loco-Rails 7
- Loco-Rails works with Rails 7 and Ruby 3.1
- it drops support for Ruby 2.6
- test app uses Loco-JS v6 and Loco-JS-UI v6
-
connection.rb
template has been modified -
Breaking changes:
- Redis is required in dev env too when you use ActionCable
- internal data structures in Redis have changed. Running
FLUSHDB
is recommended
- Loco-JS-Core has been updated to v0.2
- Breaking changes:
received_signal
instance method ofNotificationCenter
has been renamed toreceived_message
Loco.configure
initialization method requires a block
- Loco-JS and Loco-JS-Model are no longer distributed with Loco-Rails and have to be installed using
npm
- all generators, generating legacy
CoffeeScript
code, have been removed
- Loco-JS and Loco-JS-Model have been updated
- changes in the front-end architecture - Loco-JS-Model has been extracted from Loco-JS
- Loco-JS dropped the dependency on jQuery. So it officially has no dependencies ๐
- Ability to specify Redis instance through configuration
-
emit_to
- send messages to chosen recipients over WebSocket connection (an abstraction on the top ofActionCable
) -
Communication Hubs - create virtual rooms, add members and
emit_to
these hubs messages using WebSockets. All in 2 lines of code! -
now
emit
uses WebSocket connection by default (if available). But it can automatically switch to AJAX polling in case of unavailability. And all the notifications will be delivered, even those that were sent during this lack of a connection. ๐ If you useActionCable
solely and you lost connection to the server, then all the messages that were sent in the meantime are gone ๐ญ.
๐ฅ Only version 4 is under support and development.
Informations about all releases are published on Twitter
Loco-Rails is released under the MIT License.
Zbigniew Humeniuk from Art of Code