Event channels

On a Feathers server with a real-time transport (Socket.io or Primus) set up, event channels determine which connected clients to send real-time events to and how the sent data should look like.

This chapter describes:

Important: If you are not using a real-time transport server (e.g. when making a REST only API or using Feathers on the client), channel functionality is not going to be available.

Some examples where channels are used:

  • Real-time events should only be sent to authenticated users
  • Users should only get updates about messages if they joined a certain chat room
  • Only users in the same organization should receive real-time updates about their data changes
  • Only admins should be notified when new users are created
  • When a user is created, modified or removed, non-admins should only receive a “safe” version of the user object (e.g. only email, id and avatar)

Example

The example below shows the generated channels.js file illustrating how the different parts fit together:

  1. module.exports = function(app) {
  2. app.on('connection', connection => {
  3. // On a new real-time connection, add it to the
  4. // anonymous channel
  5. app.channel('anonymous').join(connection);
  6. });
  7. app.on('login', (payload, { connection }) => {
  8. // connection can be undefined if there is no
  9. // real-time connection, e.g. when logging in via REST
  10. if(connection) {
  11. const { user } = connection;
  12. // The connection is no longer anonymous, remove it
  13. app.channel('anonymous').leave(connection);
  14. // Add it to the authenticated user channel
  15. app.channel('authenticated').join(connection);
  16. // Channels can be named anything and joined on any condition
  17. // E.g. to send real-time events only to admins use
  18. // if(user.isAdmin) { app.channel('admins').join(connection); }
  19. // If the user has joined e.g. chat rooms
  20. // user.rooms.forEach(room => app.channel(`rooms/${room.id}`).join(connection))
  21. }
  22. });
  23. // A global publisher that sends all events to all authenticated clients
  24. app.publish((data, context) => {
  25. return app.channel('authenticated');
  26. });
  27. };

Connections

A connection is an object that represents a real-time connection. It is the same object as socket.feathers in a Socket.io and socket.request.feathers in a Primus middleware. You can add any kind of information to it but most notably, when using authentication, it will contain the authenticated user. By default it is located in connection.user once the client has authenticated on the socket (usually by calling app.authenticate() on the client).

We can get access to the connection object by listening to app.on('connection', connection => {}) or app.on('login', (payload, { connection }) => {}).

Note: When a connection is terminated it will be automatically removed from all channels.

app.on(‘connection’)

app.on('connection', connection => {}) is fired every time a new real-time connection is established. This is a good place to add the connection to a channel for anonymous users (in case we want to send any real-time updates to them):

  1. app.on('connection', connection => {
  2. // On a new real-time connection, add it to the
  3. // anonymous channel
  4. app.channel('anonymous').join(connection);
  5. });

app.on(‘login’)

app.on('login', (payload, info) => {}) is sent by the authentication module and also contains the connection in the info object that is passed as the second parameter. Note that it can also be undefined if the login happened through e.g. REST which does not support real-time connectivity.

This is a good place to add the connection to channels related to the user (e.g. chat rooms, admin status etc.)

  1. app.on('login', (payload, { connection }) => {
  2. // connection can be undefined if there is no
  3. // real-time connection, e.g. when logging in via REST
  4. if(connection) {
  5. // The user attached to this connection
  6. const { user } = connection;
  7. // The connection is no longer anonymous, remove it
  8. app.channel('anonymous').leave(connection);
  9. // Add it to the authenticated user channel
  10. app.channel('authenticated').join(connection);
  11. // Channels can be named anything and joined on any condition `
  12. // E.g. to send real-time events only to admins use
  13. if(user.isAdmin) {
  14. app.channel('admins').join(connection);
  15. }
  16. // If the user has joined e.g. chat rooms
  17. user.rooms.forEach(room => {
  18. app.channel(`rooms/${room.id}`).join(connection);
  19. });
  20. }
  21. });

Note: (user, { connection }) is an ES6 shorthand for (user, meta) => { const connection = meta.connection; }, see Destructuring assignment.

Channels

A channel is an object that contains a number of connections. It can be created via app.channel and allows a connection to join or leave it.

app.channel(…names)

app.channel(name) -> Channel, when given a single name, returns an existing or new named channel:

  1. app.channel('admins') // the admin channel
  2. app.channel('authenticated') // the authenticated channel

app.channel(name1, name2, ... nameN) -> Channel, when given multiples names, will return a combined channel. A combined channel contains a list of all connections (without duplicates) and re-directs channel.join and channel.leave calls to all its child channels.

  1. // Combine the anonymous and authenticated channel
  2. const combinedChannel = app.channel('anonymous', 'authenticated')
  3. // Join the `admins` and `chat` channel
  4. app.channel('admins', 'chat').join(connection);
  5. // Leave the `admins` and `chat` channel
  6. app.channel('admins', 'chat').leave(connection);
  7. // Make user with `_id` 5 leave the admins and chat channel
  8. app.channel('admins', 'chat').leave(connection => {
  9. return connection.user._id === 5;
  10. });

app.channels

app.channels -> [string] returns a list of all existing channel names.

  1. app.channel('authenticated');
  2. app.channel('admins', 'users');
  3. app.channels // [ 'authenticated', 'admins', 'users' ]
  4. app.channel(app.channels) // will return a channel with all connections

This is useful to e.g. remove a connection from all channels:

  1. // When a user is removed, make all their connections leave every channel
  2. app.service('users').on('removed', user => {
  3. app.channel(app.channels).leave(connection => {
  4. return user._id === connection.user._id;
  5. });
  6. });

channel.join(connection)

channel.join(connection) -> Channel adds a connection to this channel. If the channel is a combined channel, add the connection to all its child channels. If the connection is already in the channel it does nothing. Returns the channel object.

  1. app.on('login', (payload, { connection }) => {
  2. if(connection && connection.user.isAdmin) {
  3. // Join the admins channel
  4. app.channel('admins').join(connection);
  5. // Calling a second time will do nothing
  6. app.channel('admins').join(connection);
  7. }
  8. });

channel.leave(connection|fn)

channel.leave(connection|fn) -> Channel removes a connection from this channel. If the channel is a combined channel, remove the connection from all its child channels. Also allows to pass a callback that is run for every connection and returns if the connection should be removed or not. Returns the channel object.

  1. // Make the user with `_id` 5 leave the `admins` channel
  2. app.channel('admins').leave(connection => {
  3. return connection.user._id === 5;
  4. });

channel.filter(fn)

channel.filter(fn) -> Channel returns a new channel filtered by a given function which gets passed the connection.

  1. // Returns a new channel with all connections of the user with `_id` 5
  2. const userFive = app.channel(app.channels)
  3. .filter(connection => connection.user._id === 5);

channel.send(data)

channel.send(data) -> Channel returns a copy of this channel with customized data that should be sent for this event. Usually this should be handled by modifying either the service method result or setting client “safe” data in context.dispatch but in some cases it might make sense to still change the event data for certain channels.

What data will be sent as the event data will be determined by the first available in the following order:

  1. data from channel.send(data)
  2. context.dispatch
  3. context.result
  1. app.on('connection', connection => {
  2. // On a new real-time connection, add it to the
  3. // anonymous channel
  4. app.channel('anonymous').join(connection);
  5. });
  6. // Send the `users` `created` event to all anonymous
  7. // users but use only the name as the payload
  8. app.service('users').publish('created', data => {
  9. return app.channel('anonymous').send({
  10. name: data.name
  11. });
  12. });

Note: If a connection is in multiple channels (e.g. users and admins) it will get the data from the first channel that it is in.

channel.connections

channel.connections -> [ object ] contains a list of all connections in this channel.

channel.length

channel.length -> integer returns the total number of connections in this channel.

Publishing

Publishers are callback functions that return which channel(s) to send an event to. They can be registered at the application and the service level and for all or specific events. A publishing function gets the event data and context object ((data, context) => {}) and returns a named or combined channel or an array of channels. Only one publisher can be registered for one type. Besides the standard service event names an event name can also be a custom event. context is the context object from the service call or an object containing { path, service, app, result } for custom events.

service.publish([event,] fn)

service.publish([event,] fn) -> service registers a publishing function for a specific service for a specific event or all events if no event name was given.

  1. app.on('login', (payload, { connection }) => {
  2. // connection can be undefined if there is no
  3. // real-time connection, e.g. when logging in via REST
  4. if(connection && connection.user.isAdmin) {
  5. app.channel('admins').join(connection);
  6. }
  7. });
  8. // Publish all messages service events only to its room channel
  9. app.service('messages').publish((data, context) => {
  10. return app.channel(`rooms/${data.roomId}`);
  11. });
  12. // Publish the `created` event to admins and the user that sent it
  13. app.service('users').publish('created', (data, context) => {
  14. return [
  15. app.channel('admins'),
  16. app.channel(app.channels).filter(connection =>
  17. connection.user._id === context.params.user._id
  18. )
  19. ];
  20. });

app.publish([event,] fn)

app.publish([event,] fn) -> app registers a publishing function for all services for a specific event or all events if no event name was given.

  1. app.on('login', (payload, { connection }) => {
  2. // connection can be undefined if there is no
  3. // real-time connection, e.g. when logging in via REST
  4. if(connection) {
  5. app.channel('authenticated').join(connection);
  6. }
  7. });
  8. // Publish all events to all authenticated users
  9. app.publish((data, context) => {
  10. return app.channel('authenticated');
  11. });
  12. // Publish the `log` custom event to all connections
  13. app.publish('log', (data, context) => {
  14. return app.channel(app.channels);
  15. });

Publisher precedence

The first publisher callback found in the following order will be used:

  1. Service publisher for a specific event
  2. Service publisher for all events
  3. App publishers for a specific event
  4. App publishers for all events

Keeping channels updated

Since every application will be different, keeping the connections assigned to channels up to date (e.g. if a user joins/leaves a room) is up to you.

In general, channels should reflect your persistent application data. This means that it normally isn’t necessary for e.g. a user to request to directly join a channel. This is especially important when running multiple instances of an application since channels are only local to the current instance.

Instead, the relevant information (e.g. what rooms a user is currently in) should be stored in the database and then the active connections can be re-distributed into the appropriate channels listening to the proper service events.

The following example updates all active connections for a given user when the user object (which is assumed to have a rooms array being a list of room ids the user has joined) is updated or removed:

  1. // Join a channel given a user and connection
  2. const joinChannels = (user, connection) => {
  3. app.channel('authenticated').join(connection);
  4. // Assuming that the chat room/user assignment is stored
  5. // on an array of the user
  6. user.rooms.forEach(room =>
  7. app.channel(`rooms/${roomId}`).join(connection)
  8. );
  9. }
  10. // Get a user to leave all channels
  11. const leaveChannels = user => {
  12. app.channel(app.channels).leave(connection =>
  13. connection.user._id === user._id
  14. );
  15. };
  16. // Leave and re-join all channels with new user information
  17. const updateChannels = user => {
  18. // Find all connections for this user
  19. const { connections } = app.channel(app.channels).filter(connection =>
  20. connection.user._id === user._id
  21. );
  22. // Leave all channels
  23. leaveChannels(user);
  24. // Re-join all channels with the updated user information
  25. connections.forEach(connection => joinChannels(user, connection));
  26. }
  27. app.on('login', (payload, { connection }) => {
  28. if(connection) {
  29. // Join all channels on login
  30. joinChannels(connection.user, connection);
  31. }
  32. });
  33. // On `updated` and `patched`, leave and re-join with new room assignments
  34. app.service('users').on('updated', updateChannels);
  35. app.service('users').on('patched', updateChannels);
  36. // On `removed`, remove the connection from all channels
  37. app.service('users').on('removed', leaveChannels);

Note: The number active connections is usually one (or none) but unless you prevent it explicitly Feathers is not preventing multiple logins of the same user (e.g. with two open browser windows or on a mobile device).