A websocket connection is a way to exchange data between browser and server with a persistent connection. This type of connection is perfect for applications that require continous data exchange like multiplayer games, collaborative white boards, sports tickers, and chats.
I started to consider websockets in Salesforce when I noticed that their native Chatter component required a manual refresh before the latest chat messages would display. Why couldn't it automatically display the incoming chat messages with websockets?
I discovered that Salesorce databases and their backend language Apex, only supports HTTP/1. Websockets or GRPC(HTTP/2) are bi-directional and not supported by Salesforce natively so you can't achieve that magical refresh with Chatter using websockets. Bummer, right? (Well, they probably could use http polling but it's potentially resource intensive and maybe there are other server limitations that I'm not aware of too.)
But there is a way to use Websockets with Salesforce and that's what I set out to experiment with here.
The Websocket Server
Knowing that I couldn't host a Websocket server in Salesforce, I turned to Heroku (although any other type of server that supports this protocol will work). With Heroku, you can create a simple Node server in seconds and integrate websockets with it for communicating with your Salesforce org.
While you could certainly use the native Websocket API, I've used socket.io in the past and liked some of the fallback features it provides (like long polling) so I decided to integrate that.
The start of our basic Node server with Websockets looks like this:
'use strict'; | |
const express = require('express'); | |
const socketIO = require('socket.io'); | |
const PORT = process.env.PORT || 3000; | |
const INDEX = '/index.html'; | |
const server = express() | |
.use((req, res) => res.sendFile(INDEX, { root: __dirname })) | |
.listen(PORT, () => console.log(`Listening on ${PORT}`)); | |
const io = socketIO(server); | |
io.on('connection', (socket) => { | |
console.log('Client connected'); | |
socket.on('disconnect', () => console.log('Client disconnected')); | |
}); | |
setInterval(() => io.emit('time', new Date().toTimeString()), 1000); |
As I continue to build out my chat component, I'll make several additions to this initial server file to accomodate the different websocket events I'm passing to it. You can see the complete server.js
file on my SF Chat Websocket Server repo.
Deploying the Server to Heroku
While there are several different ways to deploy your server to Heroku, probably the simplest is to deploy from a Github repo.
For this project, I created a repo that you can also use to deploy to Heroku directly just by clicking the "Deploy to Heroku" button:
https://github.com/jamigibbs/sf-chat-websocket-server
Or you could simply create a new app in Heroku, connect it to your Github repo, and make deployments there:
Chat Component Overview
For my chat component, I wanted to implement three basic features:
- Display a single chat room with active chat users listed in a sidebar.
- Allow chat users to enter and leave the chatroom.
- Display a visual indicator when someone is typing.
So our chat room will look something like this with active users on the left side, chatroom messages displayed in the center, and an input field below for the current user to add messages to the chatroom:
Custom Object and Fields
To keep track of the chat room messages, we'll need to create a single custom object called Chat Message (Chat_Message_c
). On that object, there are two custom fields:
- Content__c (Textarea)
- User__c (a lookup to the User object)
Additionally, we'll need to add a custom field to the User object called Chat_Active__c
(Checkbox). We're adding this field to keep track of when a user has entered or left the chatroom.
Apex Queries
Now that we have our object and fields setup, there are some Apex queries we'll have to create for the frontend:
- Get chat messages
- Get active chat users
- Set chat user active
- Set chat user inactive
These are all pretty straight forward SOQL queries especially when we're only dealing with a single chat room. But you'll notice that I'm limiting our messages to only those created today
which isn't a particuarly realistic chat scenario. Ideally we would be able to handle multiple chat rooms and limit our query to that specific room. Multiple chat rooms were out of scope for this particular exercise so we're just displaying messages for the current day.
public with sharing class ChatController { | |
@AuraEnabled(cacheable=true) | |
public static List<Chat_Message__c> getTodayMessages() { | |
List<Chat_Message__c> messageList; | |
try { | |
messageList = [ | |
SELECT Id, Content__c, CreatedDate, User__r.Name, User__r.MediumPhotoUrl | |
FROM Chat_Message__c | |
WHERE CreatedDate = today | |
ORDER BY CreatedDate DESC | |
]; | |
} catch(Exception e) { | |
System.debug(e.getMessage()); | |
return null; | |
} | |
return messageList; | |
} | |
@AuraEnabled(cacheable=true) | |
public static List<User> getActiveChatUsers() { | |
List<User> userList; | |
try { | |
userList = [ | |
SELECT Id, CreatedDate, Name, MediumPhotoUrl | |
FROM User | |
WHERE Chat_Active__c = true | |
]; | |
} catch(Exception e) { | |
System.debug(e.getMessage()); | |
return null; | |
} | |
return userList; | |
} | |
@AuraEnabled | |
public static User setUserChatActive() { | |
User userToUpdate; | |
try { | |
userToUpdate = [ | |
SELECT Id | |
FROM User | |
WHERE Id = :UserInfo.getUserId() | |
]; | |
userToUpdate.Chat_Active__c = true; | |
update userToUpdate; | |
} catch(DmlException e) { | |
System.debug('An unexpected error has occurred: ' + e.getMessage()); | |
} | |
return userToUpdate; | |
} | |
@AuraEnabled | |
public static User setUserChatInactive() { | |
User userToUpdate; | |
try { | |
userToUpdate = [ | |
SELECT Id | |
FROM User | |
WHERE Id = :UserInfo.getUserId() | |
]; | |
userToUpdate.Chat_Active__c = false; | |
update userToUpdate; | |
} catch(DmlException e) { | |
System.debug('An unexpected error has occurred: ' + e.getMessage()); | |
} | |
return userToUpdate; | |
} | |
} |
You can see the complete ChatController
class on my LWC Websocket Chat repo.
Socket events
Now we can finally connect our component with the Node websocket server we setup earlier. I mentioned before that I'm using socket.io so the first step is to import the 3rd party script into the component.
The socket.io script will need to be added as a static resource and loaded from the resourceUrl
module.
You'll also need to pass the websocket server url (in our case, it's the Heroku link) from a custom label. The websocket server url will be the Heroku app link but with wss
protocol instead of http
.
For example: wss://my-heroku-app.herokuapp.com/
:
import { LightningElement, api, wire } from 'lwc'; | |
import { loadScript } from 'lightning/platformResourceLoader'; | |
import SOCKET_IO_JS from '@salesforce/resourceUrl/socketiojs'; | |
import USER_ID from '@salesforce/user/Id'; | |
// Your Heroku server link as a custom label and looks something like this: | |
// wss://my-heroku-app.herokuapp.com/ | |
import WEBSOCKET_SERVER_URL from '@salesforce/label/c.websocket_server_url'; | |
import getTodayMessages from '@salesforce/apex/ChatController.getTodayMessages'; | |
import getActiveChatUsers from '@salesforce/apex/ChatController.getActiveChatUsers'; | |
import setUserChatActive from '@salesforce/apex/ChatController.setUserChatActive'; | |
import setUserChatInactive from '@salesforce/apex/ChatController.setUserChatInactive'; | |
export default class WebsocketChat extends LightningElement { | |
@api userId = USER_ID; | |
@api timeString; | |
@api message; | |
@api error; | |
@api isChatActive = false; | |
@api isTyping = false; | |
_socketIoInitialized = false; | |
_socket; | |
@wire(getRecord, {recordId: USER_ID, fields: [CHAT_ACTIVE_FIELD]}) | |
wiredUser({error, data}) { | |
if (error) { | |
this.error = error; | |
} else if (data) { | |
this.isChatActive = data.fields.Chat_Active__c.value; | |
} | |
} | |
@wire(getTodayMessages) | |
wiredMessages | |
@wire(getActiveChatUsers) | |
wiredChatUsers | |
/** | |
* Loading the socket.io script. | |
*/ | |
renderedCallback(){ | |
if (this._socketIoInitialized) { | |
return; | |
} | |
this._socketIoInitialized = true; | |
Promise.all([ | |
loadScript(this, SOCKET_IO_JS), | |
]) | |
.then(() => { | |
this.initSocketIo(); | |
}) | |
.catch(error => { | |
// eslint-disable-next-line no-console | |
console.error('loadScript error', error); | |
this.error = 'Error loading socket.io'; | |
}); | |
} | |
initSocketIo(){ | |
// eslint-disable-next-line no-undef | |
this._socket = io.connect(WEBSOCKET_SERVER_URL); | |
// ADDITIONAL SOCKET EVENT HANDLING WILL GO HERE | |
} | |
} |
When communicating between the client and the server (and vice versa), there will be an event that's emitted (emit
) and an event that's received (on
);
To build out our chat app, we're going to use a series of emitted and received events for communicating chat actions like when a message is added to the chat, when a user leaves, and when a user enters.
Chat Message Added
A good example of how our socket events will flow can be demonstrated when a user adds a chat message.
When a message is submitted (in this case, the enter key is pressed on the input field), we'll first add a new Chat_Message_c
record. After that's successful, we'll emit
a socket message called "transmit" which will let our other users know to refresh their data for a new message:
websocketChat.html - The user has entered a message into an input field:
<form class="send-message-form"> | |
<input type="text" class="message-input"> | |
<button type="submit">Send</button> | |
</form> |
websocketChat.js - When they submit/press enter, a chat message record is created using the createRecord module and on success, we emit the "transmit" socket event:
import { getRecord } from 'lightning/uiRecordApi'; | |
import MESSAGE_OBJECT from '@salesforce/schema/Chat_Message__c'; | |
import CONTENT_FIELD from '@salesforce/schema/Chat_Message__c.Content__c'; | |
import USER_FIELD from '@salesforce/schema/Chat_Message__c.User__c'; | |
// .... | |
const messageInput = this.template.querySelector('.message-input'); | |
messageInput.addEventListener('keydown', (event) => { | |
if (event.which === 13 && event.shiftKey === false) { | |
event.preventDefault(); | |
const fields = {}; | |
fields[CONTENT_FIELD.fieldApiName] = messageInput.value; | |
fields[USER_FIELD.fieldApiName] = this.userId; | |
const message = { apiName: MESSAGE_OBJECT.objectApiName, fields }; | |
createRecord(message) | |
.then(() => { | |
// Reset the form input field. | |
messageInput.value = ''; | |
// Refresh the message data for other active users. | |
this._socket.emit('transmit'); | |
// Refresh the message data for the current user. | |
return refreshApex(this.wiredMessages); | |
}) | |
.catch(error => { | |
// eslint-disable-next-line no-console | |
console.error('error', error); | |
this.error = 'Error creating message'; | |
}); | |
} | |
}); |
server.js - The server, listening for the "transmit" event, emits another event back to the web component called "chatupdated":
socket.on('transmit', () => { | |
io.emit('chatupdated'); | |
}); |
websocketChat.js (again) - Now all active components receive this "chatupdated" event which refreshes the messages list to display (nearly) instantaneously the latest message (or however long it takes Salesforce to refresh its cache via refreshApex):
this._socket.on('chatupdated', () => { | |
return refreshApex(this.wiredMessages); | |
}); |
Entering and Leaving Chat
Similar to how the chat messages work, we can display when a user enters or leaves the chat room by toggling a Chat_Active__c
checkbox on their user record and update the sidebar active user list accordingly.
When the user clicks enter or leave the chat, this triggers a socket event chain similar to when we added a new message:
websocketChat.js -- When the user clicks the enter or leave chat button, the user record is updated and we emit the event userEnteredChat
or userLeftChat
to our socket server:
handleEnterChat() { | |
setUserChatActive() | |
.then((res) => { | |
this.isChatActive = res.Chat_Active__c; | |
this._socket.emit('userEnteredChat'); | |
return refreshApex(this.wiredChatUsers); | |
}) | |
.catch(error => { | |
// eslint-disable-next-line no-console | |
console.error('handleEnterChat error', error); | |
this.error = 'Error updating user record'; | |
}); | |
} | |
handleLeaveChat() { | |
setUserChatInactive() | |
.then((res) => { | |
this.isChatActive = res.Chat_Active__c; | |
this._socket.emit('userLeftChat'); | |
return refreshApex(this.wiredChatUsers); | |
}) | |
.catch(error => { | |
// eslint-disable-next-line no-console | |
console.error('handleLeaveChat error', error); | |
this.error = 'Error updating user record'; | |
}); | |
} |
server.js -- On the websocket server, those events are captured and it sends back to Salesforce a refreshChatUsers
event:
socket.on('userEnteredChat', () => { | |
io.emit('refreshChatUsers'); | |
}); | |
socket.on('userLeftChat', () => { | |
io.emit('refreshChatUsers'); | |
}); |
websocketChat.js (again) - Finally, our component will refresh the active user list for all users connected to the chat in the same way that we refreshed the chat messages using refreshApex:
this._socket.on('refreshChatUsers', () => { | |
return refreshApex(this.wiredChatUsers); | |
}); |
Completed Project
To view the completed chat component that includes all of the features previously mentioned, the project is available for you to install on your own scratch org from the following repo:
https://github.com/jamigibbs/lwc-websocket-chat
The correspoding node websocket server is also available here:
https://github.com/jamigibbs/sf-chat-websocket-server
Final Thoughts
Because we still have to make HTTP requests through the Salesforce server in order to add and fetch records, this isn't truely seemless like it would be if we were also storing data on the websocket server itself. There isn't a continous data exchange. Ideally, we could use websockets for more than just a pubsub type service where we're just bouncing events back and forth.
But it has value in creating a persistent connection and "magically" refreshing data for all connected users without resorting to polling the Salesforce server. This in itself is a pretty cool feature. I think the value that brings might need to be weighed against the effort it would take to maintain a separate external server whose only purpose is to trigger a data refresh though. I could also see some issues with security and the need to implement authentication on the external server to safeguard against rogue connections.
There are a lot of interesting nuances here though and it was fun to experiment with the technology within Salesforce. Thanks for reading!