I work on an existing Django application, and during a recent hack week, wanted to toy with the idea of adding user notifications. There are definitely customer use cases for this, but it was also a good excuse for me to try out some new technologies! I was able to get a proof of concept working in a couple days, and had a lot of fun doing it, so thought I’d share what I built!
Goal: when a specific thing happens somewhere in the system that creates a notification for a user, immediately increase the count in their notification bubble in the header, or if this is their first unread notification, make the notification bubble appear (without any page reloads).
Because this was a hack week project, it’s not in production (and I don’t expect that it’s production-ready…I didn’t even make any attempts at security).
This was my first foray into both Node and websockets, so it’s entirely possible that my implementation has some newbie mistakes in it. I’m writing this because I had fun working on it, I learned a lot…and because I’d love to learn more! So if you’ve done something like this before or just have ideas on how to do it better, I’d love to hear them!
If you’re new to the idea of websockets entirely, and unsure how they work technically, I found this blog from Treehouse to be a good introduction, or for more technical depth on the protocol, this introduction from Mozilla.
So in order to make notifications work in our existing Django application, here are the pieces we’ll need (we’ll go into more depth into each of these later):
I chose Node for this piece partially because I’d never used it before and was curious, but mainly because they make it so easy to implement websockets. If you’ve never built a Node app before (I hadn’t!), I found this blog to be a good introduction to how to get one set up, use npm, and install dependencies. I didn’t use any of the routing suggested in it, and made some other adjustments and additions, but that’s a great starting place if you’re creating a brand new app for this purpose. Ok, let’s go!
'use strict';
let express = require('express');
let http = require('http');
let ws = require('ws');
let url = require('url');
express
and ws
will need to be installed using npm install express --save
. ws
is the websocket library I chose to use — they say it’s the fastest! Here are the docs for it. You’ll see what we use http
and url
for below.
We’ll then add a constant for the port:
let PORT = process.env.PORT || 3000;
This allows you to define the port you want to hit locally for testing (3000, in this case), and not worry about it once you deploy.
We’ll set up our app, like so:
let app = express()
let server = http.Server(app);
server.listen(PORT, () => console.log(`Client Listening on $ `));
This allows us to establish a regular HTTP server that we can use for the incoming HTTP request we’ll talk about later, as well as for our websocket server (The websocket handshake starts with a standard HTTP request — the Mozilla docs linked above explain this in more detail).
let wssClient = new ws.Server({ server: server, path: '/client'})
This establishes a new websocket server using the HTTP server we established above, and defines the path that can be used to open connections. We’ll use that to open the connection in the user’s browser on our existing Django application.
I included the following in my app, which was taken almost directly from the ws documentation:
function noop() {}
function heartbeat() {
this.isAlive = true;
}
const clientInterval = setInterval(function ping() {
wssClient.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping(noop);
});
}, 30000);
Aside from closing broken connections, it has the side benefit of making the app work at all, if you end up deploying to something like Heroku. Heroku closes inactive connections after 30 seconds, which is obviously not ideal for something like a websocket, which you want to keep open for as long as the user’s browser is open.
Ok, now that we have everything set up, on to the fun stuff! (jk, it’s all fun…)
let userSockets = {}
wssClient.on('connection', (socket, request) => {
socket.isAlive = true;
socket.on('pong', heartbeat);
const parameters = url.parse(request.url, true);
const userId = parameters.query.user_id;
userSockets[userId] = socket
console.log("Connection established for user " + userId)
})
This section tells the server what to do when it receives a connection. First, the housekeeping: it’s alive, and when it’s pinged, send a heartbeat.
Then, get the userId
out of the parameters. This section assumes the connection was opened with a query parameter in the path containing the user_id
. Once we’ve established the socket and found the user_id
, we’ll store it in memory in our userSockets
object, so we can access it later, when we need to send messages using the right user’s socket.
Ok, so we’ve set up our Node server, let’s do something with it! Over in our existing Django app (where we want users to receive notifications), we need to open a connection with the socket server on page load. In my case, I put the code below in a Django template that is used as the base for all other templates — so it will be run regardless of which page the user is on. We’ll add to this code later, but here are the pieces that open a connection on load:
{% if request.user.is_authenticated %}
<script>
var Notifications = function(url) {
socket = new WebSocket(url + '?user_id=' + { });
socket.onopen = function() {
console.log('Successful Connection To Server.');
};
};
var host = 'ws://localhost:3000/client';
var socket = Notifications(host);
</script>
{% endif %}
WebSocket
is by default accessible in JavaScript code. Documentation can be found here, but all it requires is the url where the socket server can be found. The code above uses localhost, but of course, if you’ve deployed the Node app we created above, you can use that url here instead (but note the use of ws://
here instead of http://
— or you can even change it to wss://
, assuming you’ve deployed to somewhere with an SSL certificate), no port necessary.
The .onopen
function is one of the builtin WebSocket
functions that is an event listener that is called when the socket is open — docs here. Nothing will happen yet, but check your JavaScript console when you load a page, and if all is well you should see the “Successful Connection To Server.” message!
If you’re totally new to signals in Django, I wrote a post about them a while back, but in short: senders notify receivers when something happened. Sounds about like what we need! Let’s assume we want to notify users anytime another user sends them a Message
— or rather, we want to notify them each time a Message
is created in the database, of which they are the recipient. Here’s what our receiver might look like:
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=Message, dispatch_uid='notification_message_post_save')
def notification_message_post_save(sender, instance=None, created=None, **kwargs):
if not created:
return
create_message_notifications.delay(instance.id)
This is a pretty bare receiver. It checks if the object was created, and returns if it wasn’t — we only want to notify users of new messages not any edits to existing messages. It then enqueues a background task, passing the message’s id along with it (side note — always enqueue object ids, not the objects themselves. Objects from the Django ORM are not JSON serializable). You could do the work in the receiver itself, but since we need to make an HTTP request, it’s typically better not to make users wait for that to complete before loading their page. So here’s the meat of it, in the background task we’ve enqueued:
import requests
from annoying.functions import get_object_or_None
from celery import shared_task
@shared_task(ignore_result=True)
def create_message_notifications(message_id):
message = get_object_or_None(Message, id=message_id)
if not message:
return
notification = Notification.objects.create(
...
)
requests.get('http://localhost:3000/new/{}'.format(user_id))
Let’s go back to our Node app. The line above that reads request.get('http://localhost:3000/new/{}').format(user_id))
won’t work yet — there’s no endpoint on our Node app to receive it. So let’s add one!
app.get('/new/:user_id', (req, res) => {
let userId = req.params.user_id
let socket = userSockets[userId]
if (typeof(socket) !== "undefined") {
socket.send('')
}
res.send("")
})
A few things to note:
user_id
in the path, so we can find the correct socket to send the message touserSockets[userId]
userSockets[userId]
returned a socket — the Django signal will send a request to this endpoint even if the user isn’t actively using the site, in which case they won’t have an open socket connection, and we therefore won’t get anything back!socket.send()
call — my implementation simply updates a notification bubble telling the user how many unread notifications they have, so it’s not important what the content of the notification is. All that matters is that there is one, and we will assume that each time the socket sends a message, we’ve received a new notification, and can update our count accordingly. Your implementation may be different — you can send any JSON data you want(JSON.stringify({})
)!Ok, our WebSocket server just sent us a message — now we need to do something with it! Below is the same code we looked at above, with some additions:
{% if request.user.is_authenticated %}
<script>
var notificationCount = { }
var Notifications = function(url) {
socket = new WebSocket(url + '?user_id=' + { });
socket.onopen = function() {
console.log('Successful Connection To Server.'); };
socket.onmessage = function (event) {
notificationCount += 1;
updateUnreadNotificationCount(notificationCount);
}
};
var host = 'ws://localhost:3000/client';
var socket = Notifications(host);
</script>
{% endif %}
Ok, let’s look at the new pieces:
var notificationCount = { }
give us a starting place so we know how many the user had on page load, so we can increment the count each time we receive a message from our socket (it assumes UNREAD_NOTIFICATION_COUNT
is in your global context processors)socket.onmessage
bit is the event listener that is called when a message is received from the server — you’ll notice there’s an assumption here that I’ve got a updateUnreadNotificationCount
JavaScript function that I can call — this function updates my HTML accordingly so the user’s notification bubble appears if they didn’t have one before, or the number increments if they did. Since I don’t know the HTML structure of your app or what you want your notification bubble to look like, you’re on your own here!As I mentioned at the beginning, this was my first time using both Node.js and WebSockets. I had a lot of fun working on this, but I also recognize that it’s not production ready, and may not follow best practices. I’m always open to learning from folks who are more well-versed in these technologies, if anyone has feedback on the implementation!