Adding Real-Time Notifications to Ghost CMS Using MongoDB and Server-Sent Events
TQ
Tobias Quante7 min read • Published Aug 14, 2023 • Updated Aug 14, 2023
Rate this tutorial
Ghost is an open-source blogging platform. Unlike other content management systems like WordPress, its focus lies on professional publishing.
This ensures the core of the system remains lean. To integrate third-party applications, you don't even need to install plugins. Instead, Ghost offers a feature called Webhooks which runs while you work on your publication.
You are likely familiar with the concept of an HTTP session. A client sends a request, the server responds and then closes the connection. When using server-sent events (SSEs), said connection remains open. This allows the server to continue writing messages into the response.
Like Websockets (WS), apps and websites use SSEs for real-time communication. Where WSs use a dedicated protocol and work in both directions, SSEs are unidirectional. They use plain HTTP endpoints to write a message whenever an event occurs on the server side.
1 const subscription = new EventSource("https://example.io/subscribe")
Now that we’ve looked at the periphery of our application, it's time to present its core. We'll use MongoDB to store a subset of the received Ghost Webhook data. On top of that, we'll use MongoDB Change Streams to watch our webhook collection.
In a nutshell, Change Streams register data flowing into our database. We can subscribe to this data stream and react to it. Reacting means sending out SSE messages to connected clients whenever a new webhook is received and stored.
The following Javascript code showcases a simple Change Stream subscription.
1 import {MongoClient} from 'mongodb'; 2 3 const client = new MongoClient("<mongodb-url>"); 4 const ghostDb = client.db('ghost'); 5 const ghostCollection = ghostDb.collection('webhooks'); 6 const ghostChangeStrem = ghostCollection.watch(); 7 8 ghostChangeStream.on('change', document => { 9 /* document is the MongoDB collection entry, e.g. our webhook */ 10 });
Its event-based nature matches perfectly with webhooks and SSEs. We can react to newly received webhooks where the data is created, ensuring data integrity over our whole application.
We need an extra application layer to propagate these changes to connected clients. I've decided to use Typescript and Express.js, but you can use any other server-side framework. You will also need a dedicated MongoDB instance*. For a quick start, you can sign up for MongoDB Atlas. Then, create a free cluster and connect to it.
Let's get started by cloning the
1-get-started
branch from this Github repository:1 # ssh 2 $ git clone git@github.com:tq-bit/mongodb-article-mongo-changestreams.git 3 4 # HTTP(s) 5 $ git clone https://github.com/tq-bit/mongodb-article-mongo-changestreams.git 6 7 # Change to the starting branch 8 $ git checkout 1-get-started 9 10 # Install NPM dependencies 11 $ npm install 12 13 # Make a copy of .env.example 14 $ cp .env.example .env
Make sure to fill out the MONGO_HOST environment variable with your connection string!
Express and the database client are already implemented. So in the following, we'll focus on adding MongoDB change streams and server-sent events.
Once everything is set up, you can start the server on
http://localhost:3000
by typing1 npm run dev
The application uses two important endpoints which we will extend in the next sections:
/api/notification/subscribe
<- Used by EventSource to receive event messages/api/notification/article/create
<- Used as a webhook target by Ghost
Open the cloned project in your favorite code editor. We'll add our SSE logic under
src/components/notification/notification.listener.ts
.In a nutshell, implementing SSE requires three steps:
- Write out an HTTP status 200 header.
- Write out an opening message.
- Add event-based response message handlers.
We’ll start sending a static message and revisit this module after adding ChangeStreams.
You can also
git checkout 2-add-sse
to see the final result.Writing the HTTP header informs clients of a successful connection. It also propagates the response's content type and makes sure events are not cached.
Add the following code to the function
subscribeToArticleNotification
inside:1 // Replace 2 // TODO: Add function to write the head 3 // with 4 console.log('Step 1: Write the response head and keep the connection open'); 5 res.writeHead(200, { 6 'Content-Type': 'text/event-stream', 7 'Cache-Control': 'no-cache', 8 Connection: 'keep-alive' 9 });
The first message sent should have an event type of 'open'. It is not mandatory but helps to determine whether the subscription was successful.
Append the following code to the function
subscribeToArticleNotification
:1 // Replace 2 // TODO: Add functionality to write the opening message 3 // with 4 console.log('Step 2: Write the opening event message'); 5 res.write('event: open\n'); 6 res.write('data: Connection opened!\n'); // Data can be any string 7 res.write(`id: ${crypto.randomUUID()}\n\n`);
We can customize the content and timing of all further messages sent. Let's add a placeholder function that sends messages out every five seconds for now. And while we’re at it, let’s also add a handler to close the client connection:
Append the following code to the function
subscribeToArticleNotification
:1 setInterval(() => { 2 console.log('Step 3: Send a message every five seconds'); 3 res.write(`event: message\n`); 4 res.write(`data: ${JSON.stringify({ message: 'Five seconds have passed' })}\n`); 5 res.write(`id: ${crypto.randomUUID()}\n\n`); 6 }, 5000); 7 8 9 // Step 4: Handle request events such as client disconnect 10 // Clean up the Change Stream connection and close the connection stream to the client 11 req.on('close', () => { 12 console.log('Step 4: Handle request events such as client disconnect'); 13 res.end(); 14 });
To check if everything works, visit
http://localhost:3000/api/notification/subscribe
.Let's visit
src/components/notification/notification.model.ts
next. We'll add a simple insert
command for our database into the function createNotificiation
:You can also
git checkout 3-webhook-handler
to see the final result.1 // Replace 2 // TODO: Add insert one functionality for DB 3 // with 4 return notificationCollection.insertOne(notification);
And on to
src/components/notification/notification.controller.ts
. To process incoming webhooks, we'll add a handler function into handleArticleCreationNotification
:1 // Replace 2 // TODO: ADD handleArticleCreationNotification 3 // with 4 const incomingWebhook: GhostWebhook = req.body; 5 await NotificationModel.createNotificiation({ 6 id: crypto.randomUUID(), 7 ghostId: incomingWebhook.post?.current?.id, 8 ghostOriginalUrl: incomingWebhook.post?.current?.url, 9 ghostTitle: incomingWebhook.post?.current?.title, 10 ghostVisibility: incomingWebhook.post?.current?.visibility, 11 type: NotificationEventType.PostPublished, 12 }); 13 14 res.status(200).send('OK');
This handler will pick data from the incoming webhook and insert a new notification.
1 curl -X POST -d '{ 2 "post": { 3 "current": { 4 "id": "sj7dj-lnhd1-kabah9-107gh-6hypo", 5 "url": "http://localhost:2368/how-to-create-realtime-notifications", 6 "title": "How to create realtime notifications", 7 "visibility": "public" 8 } 9 } 10 }' http://localhost:3000/api/notification/article/create
You can also test the insert functionality by using Postman or VSCode REST client and then check your MongoDB collection. There is an example request under
/test/notification.rest
in the project's directory, for your convenience.So far, we can send SSEs and insert Ghost notifications. Let's put these two features together now.
Earlier, we added a static server message sent every five seconds. Let's revisit
src/components/notification/notification.listener.ts
and make it more dynamic.First, let's get rid of the whole
setInterval
and its callback. Instead, we'll use our notificationCollection
and its built-in method watch
. This method returns a ChangeStream
.You can create a change stream by adding the following code above the
export default
code segment:1 const notificationStream = notificationCollection.watch();
The stream fires an event whenever its related collection changes. This includes the
insert
event from the previous section.We can register callback functions for each of these. The event that fires when a document inside the collection changes is 'change':
1 notificationStream.on('change', (next) => { 2 console.log('Step 3.1: Change in Database detected!'); 3 });
The variable passed into the callback function is a change stream document. It includes two important information for us:
- The document that's inserted, updated, or deleted.
- The type of operation on the collection.
Let's assign them to one variable each inside the callback:
1 notificationStream.on('change', (next) => { 2 // ... previous code 3 const { 4 // @ts-ignore, fullDocument is not part of the next type (yet) 5 fullDocument /* The newly inserted fullDocument */, 6 operationType /* The MongoDB operation Type, e.g. insert */, 7 } = next; 8 });
Let's write the notification to the client. We can do this by repeating the method we used for the opening message.
1 notificationStream.on('change', (next) => { 2 // ... previous code 3 console.log('Step 3.2: Writing out response to connected clients'); 4 res.write(`event: ${operationType}\n`); 5 res.write(`data: ${JSON.stringify(fullDocument)}\n`); 6 res.write(`id: ${crypto.randomUUID()}\n\n`); 7 });
And that's it! You can test if everything is functional by:
- Opening your browser under
http://localhost:3000/api/notification/subscribe
. - Using the file under
test/notification.rest
with VSCode's HTTP client. - Checking if your browser includes an opening and a Ghost Notification.
For an HTTP webhook implementation, you will need a running Ghost instance. I have added a dockerfile to this repo for your convenience. You could also install Ghost yourself locally.
To start Ghost with the dockerfile, make sure you have Docker Engine or Docker Desktop with support for
docker compose
installed.For a local installation and the first-time setup, you should follow the official Ghost installation guide.
After your Ghost instance is up and running, open your browser at
http://localhost:2368/ghost
. You can set up your site however you like, give it a name, enter details, and so on.In order to create a webhook, you must first create a custom integration. To do so, navigate into your site’s settings and click on the “Integrations” menu point. Click on “Add Webhook,” enter a name, and click on “Create.”
Inside the newly created integration, you can now configure a webhook to point at your application under
http://<host>:<port>/api/notification/article/create
*.* This URL might vary based on your local Ghost setup. For example, if you run Ghost in a container, you can find your machine's local IP using the terminal and
ifconfig
on Linux or ipconfig
on Windows.And that’s it. Now, whenever a post is published, its contents will be sent to our real-time endpoint. After being inserted into MongoDB, an event message will be sent to all connected clients.
There are a few ways to add real-time notifications to your Ghost theme. Going into detail is beyond the scope of this article. I have prepared two files, a
plugin.js
and a plugin.css
file you can inject into the default Casper theme.Try this out by starting a local Ghost instance using the provided dockerfile.
You must then instruct your application to serve the JS and CSS assets. Add the following to your
index.ts
file:1 // ... other app.use hooks 2 app.use(express.static('public')); 3 // ... app.listen()
1 <script src="http://localhost:3000/js/plugin.js"></script> 2 <link href="http://localhost:3000/css/plugin.css" rel="stylesheet">
The core piece of the plugin is the EventSource browser API. You will want to use it when integrating this application with other themes.
When going back into your Ghost publication, you should now see a small bell icon on the upper right side.
If you’ve followed along, congratulations! You now have a working real-time notification service for your Ghost blog. And if you haven’t, what are you waiting for? Sign up for a free account on MongoDB Atlas and start building. You can use the final branch of this repository to get started and explore the full power of MongoDB’s toolkit.