Server-Sent Events: A Case Study

Good day, friends!



In this tutorial, we will look at Server Sent Events: a built-in EventSource class that allows you to maintain a connection to the server and receive events from it.



You can read about what SSE is and what it is used for here .



What exactly are we going to do?



We will write a simple server that, upon the client's request, will send him data of 10 random users, and the client will use this data to generate user cards.



The server will be implemented in Node.js , the client in JavaScript. Bootstrap will be used for styling , and Random User Generator will be used as an API .



The project code is here...



If you are interested, please follow me.



Training



Create a directory sse-tut:



mkdir sse-tut


We go into it and initialize the project:



cd sse-tut

yarn init -y
// 
npm init -y


Install axios:



yarn add axios
// 
npm i axios


axios will be used to get user data.



Editing package.json:



"main": "server.js",
"scripts": {
    "start": "node server.js"
},


Project structure:



sse-tut
    --node_modules
    --client.js
    --index.html
    --package.json
    --server.js
    --yarn.lock


Contents index.html:



<head>
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
    <style>
        .card {
            margin: 0 auto;
            max-width: 512px;
        }
        img {
            margin: 1rem;
            max-width: 320px;
        }
        p {
            margin: 1rem;
        }
    </style>
</head>

<body>
    <main class="container text-center">
        <h1>Server-Sent Events Tutorial</h1>
        <button class="btn btn-primary" data-type="start-btn">Start</button>
        <button class="btn btn-danger" data-type="stop-btn" disabled>Stop</button>
        <p data-type="event-log"></p>
    </main>

    <script src="client.js"></script>
</body>


Server



Let's start implementing the server.



We open server.js.



We connect http and axios, define the port:



const http = require('http')
const axios = require('axios')
const PORT = process.env.PORT || 3000


We create a function for receiving user data:



const getUserData = async () => {
    const response = await axios.get('https://randomuser.me/api')
    //   
    console.log(response)
    return response.data.results[0]
}


Create a counter for the number of sent users:



let i = 1


We write the function of sending data to the client:



const sendUserData = (req, res) => {
    //   - 200 
    //    
    //   -  
    //  
    res.writeHead(200, {
        Connection: 'keep-alive',
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache'
    })

    //     2 
    const timer = setInterval(async () => {
        //   10 
        if (i > 10) {
            //  
            clearInterval(timer)
            //   ,    10 
            console.log('10 users has been sent.')
            //      -1
            //  ,    
            res.write('id: -1\ndata:\n\n')
            //  
            res.end()
            return
        }

        //  
        const data = await getUserData()

        //    
        // event -  
        // id -  ;    
        // retry -   
        // data - 
        res.write(`event: randomUser\nid: ${i}\nretry: 5000\ndata: ${JSON.stringify(data)}\n\n`)

        //   ,   
        console.log('User data has been sent.')

        //   
        i++
    }, 2000)

    //    
    req.on('close', () => {
        clearInterval(timer)
        res.end()
        console.log('Client closed the connection.')
      })
}


We create and start the server:



http.createServer((req, res) => {
    //     CORS
    res.setHeader('Access-Control-Allow-Origin', '*')

    //    - getUser
    if (req.url === '/getUsers') {
        //  
        sendUserData(req, res)
    } else {
        // ,   ,     ,
        //   
        res.writeHead(404)
        res.end()
    }

}).listen(PORT, () => console.log('Server ready.'))


Full server code:
const http = require('http')
const axios = require('axios')
const PORT = process.env.PORT || 3000

const getUserData = async () => {
    const response = await axios.get('https://randomuser.me/api')
    return response.data.results[0]
}

let i = 1

const sendUserData = (req, res) => {
    res.writeHead(200, {
    Connection: 'keep-alive',
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache'
    })

    const timer = setInterval(async () => {
    if (i > 10) {
        clearInterval(timer)
        console.log('10 users has been sent.')
        res.write('id: -1\ndata:\n\n')
        res.end()
        return
    }

    const data = await getUserData()

    res.write(`event: randomUser\nid: ${i}\nretry: 5000\ndata: ${JSON.stringify(data)}\n\n`)

    console.log('User data has been sent.')

    i++
    }, 2000)

    req.on('close', () => {
    clearInterval(timer)
    res.end()
    console.log('Client closed the connection.')
    })
}

http.createServer((req, res) => {
    res.setHeader('Access-Control-Allow-Origin', '*')

    if (req.url === '/getUsers') {
    sendUserData(req, res)
    } else {
    res.writeHead(404)
    res.end()
    }

}).listen(PORT, () => console.log('Server ready.'))




We execute the command yarn startor npm start. The terminal displays the message "Server ready." Opening http://localhost:3000: We are







finished with the server, go to the client side of the application.



Client



Open the file client.js.



Create a function for generating a custom card template:



const getTemplate = user => `
<div class="card">
    <div class="row">
        <div class="col-md-4">
            <img src="${user.img}" class="card-img" alt="user-photo">
        </div>
        <div class="col-md-8">
            <div class="card-body">
                <h5 class="card-title">${user.id !== null ? `Id: ${user.id}` : `User hasn't id`}</h5>
                <p class="card-text">Name: ${user.name}</p>
                <p class="card-text">Username: ${user.username}</p>
                <p class="card-text">Email: ${user.email}</p>
                <p class="card-text">Age: ${user.age}</p>
            </div>
        </div>
    </div>
</div>
`


The template is generated using the following data: user ID (if any), name, login, email address and age of the user.



We are starting to implement the main functionality:



class App {
    constructor(selector) {
        //   - 
        this.$ = document.querySelector(selector)
        //   
        this.#init()
    }

    #init() {
        this.startBtn = this.$.querySelector('[data-type="start-btn"]')
        this.stopBtn = this.$.querySelector('[data-type="stop-btn"]')
        //     
        this.eventLog = this.$.querySelector('[data-type="event-log"]')
        //    
        this.clickHandler = this.clickHandler.bind(this)
        //   
        this.$.addEventListener('click', this.clickHandler)
    }

    clickHandler(e) {
        //    
        if (e.target.tagName === 'BUTTON') {
            //   
            //       ,
            //   
            const {
                type
            } = e.target.dataset

            if (type === 'start-btn') {
                this.startEvents()
            } else if (type === 'stop-btn') {
                this.stopEvents()
            }

            //   
            this.changeDisabled()
        }
    }

    changeDisabled() {
        if (this.stopBtn.disabled) {
            this.stopBtn.disabled = false
            this.startBtn.disabled = true
        } else {
            this.stopBtn.disabled = true
            this.startBtn.disabled = false
        }
    }
//...


First, we implement closing the connection:



stopEvents() {
    this.eventSource.close()
    //   ,    
    this.eventLog.textContent = 'Event stream closed by client.'
}


Let's move on to opening the stream of events:



startEvents() {
    //          
    this.eventSource = new EventSource('http://localhost:3000/getUsers')

    //   ,   
    this.eventLog.textContent = 'Accepting data from the server.'

    //        -1
    this.eventSource.addEventListener('message', e => {
        if (e.lastEventId === '-1') {
            //  
            this.eventSource.close()
            //   
            this.eventLog.textContent = 'End of stream from the server.'

            this.changeDisabled()
        }
        //       
    }, {once: true})
}


We handle the "randomUser" custom event:



this.eventSource.addEventListener('randomUser', e => {
    //   
    const userData = JSON.parse(e.data)
    //  
    console.log(userData)

    //     
    const {
        id,
        name,
        login,
        email,
        dob,
        picture
    } = userData

    //   ,     
    const i = id.value
    const fullName = `${name.first} ${name.last}`
    const username = login.username
    const age = dob.age
    const img = picture.large

    const user = {
        id: i,
        name: fullName,
        username,
        email,
        age,
        img
    }

    //  
    const template = getTemplate(user)

    //    
    this.$.insertAdjacentHTML('beforeend', template)
})


Don't forget to implement error handling:



this.eventSource.addEventListener('error', e => {
    this.eventSource.close()

    this.eventLog.textContent = `Got an error: ${e}`

    this.changeDisabled()
}, {once: true})


Finally, we initialize the application:



const app = new App('main')


Full client code:
const getTemplate = user => `
<div class="card">
    <div class="row">
        <div class="col-md-4">
            <img src="${user.img}" class="card-img" alt="user-photo">
        </div>
        <div class="col-md-8">
            <div class="card-body">
                <h5 class="card-title">${user.id !== null ? `Id: ${user.id}` : `User hasn't id`}</h5>
                <p class="card-text">Name: ${user.name}</p>
                <p class="card-text">Username: ${user.username}</p>
                <p class="card-text">Email: ${user.email}</p>
                <p class="card-text">Age: ${user.age}</p>
            </div>
        </div>
    </div>
</div>
`

class App {
    constructor(selector) {
        this.$ = document.querySelector(selector)
        this.#init()
    }

    #init() {
        this.startBtn = this.$.querySelector('[data-type="start-btn"]')
        this.stopBtn = this.$.querySelector('[data-type="stop-btn"]')
        this.eventLog = this.$.querySelector('[data-type="event-log"]')
        this.clickHandler = this.clickHandler.bind(this)
        this.$.addEventListener('click', this.clickHandler)
    }

    clickHandler(e) {
        if (e.target.tagName === 'BUTTON') {
            const {
                type
            } = e.target.dataset

            if (type === 'start-btn') {
                this.startEvents()
            } else if (type === 'stop-btn') {
                this.stopEvents()
            }

            this.changeDisabled()
        }
    }

    changeDisabled() {
        if (this.stopBtn.disabled) {
            this.stopBtn.disabled = false
            this.startBtn.disabled = true
        } else {
            this.stopBtn.disabled = true
            this.startBtn.disabled = false
        }
    }

    startEvents() {
        this.eventSource = new EventSource('http://localhost:3000/getUsers')

        this.eventLog.textContent = 'Accepting data from the server.'

        this.eventSource.addEventListener('message', e => {
            if (e.lastEventId === '-1') {
                this.eventSource.close()
                this.eventLog.textContent = 'End of stream from the server.'

                this.changeDisabled()
            }
        }, {once: true})

        this.eventSource.addEventListener('randomUser', e => {
            const userData = JSON.parse(e.data)
            console.log(userData)

            const {
                id,
                name,
                login,
                email,
                dob,
                picture
            } = userData

            const i = id.value
            const fullName = `${name.first} ${name.last}`
            const username = login.username
            const age = dob.age
            const img = picture.large

            const user = {
                id: i,
                name: fullName,
                username,
                email,
                age,
                img
            }

            const template = getTemplate(user)

            this.$.insertAdjacentHTML('beforeend', template)
        })

        this.eventSource.addEventListener('error', e => {
            this.eventSource.close()

            this.eventLog.textContent = `Got an error: ${e}`

            this.changeDisabled()
        }, {once: true})
    }

    stopEvents() {
        this.eventSource.close()
        this.eventLog.textContent = 'Event stream closed by client.'
    }
}

const app = new App('main')




Restart the server just in case. We open http://localhost:3000. Click on the "Start" button:







We start receiving data from the server and rendering user cards.



If you click on the "Stop" button, sending data will be suspended:







Press "Start" again, sending data continues.



When the limit (10 users) is reached, the server sends an identifier with a value of -1 and closes the connection. The client, in turn, also closes the event stream:







As you can see, SSE is very similar to websockets. The disadvantage is that messages are unidirectional: messages can only be sent by the server. The advantage is automatic reconnection and ease of implementation.



Support for this technology today is 95%:







I hope you enjoyed the article. Thank you for attention.



All Articles