Server Sent Events

2023-04-16

Intro

My professional experience hasn't included a lot of "push" technology, but a friend mentioned an upcoming hockey draft and how he wants to be able to push updates to a webpage. I've heard of SSE (Server-Sent Events) and wanted to implement it.

What is it?

Server-Sent Events is a technology that allows data to be "pushed" from the server to a webpage. The browser calls an endpoint and a connection is made where the browser listens to events publish through this connection.

How to use it

Client Side Setup

The connection is made with Javascript by creating an EventSource. This new object takes a URL as its parameter. A callback function is set to the onmessage method of the new EventSource. When an event is received, the onmessage callback function is passed an MessageEvent object.

Client Javascript
// Creating an EventSource by calling a different endpoint
const eventSource = new EventSource("sse.php");

// Registering the function that gets called when an event is sent from the server
eventSource.onmessage = function(event) {
  // Do something with the event
  console.log(event.data);
 };

Server Side Setup

The page is pretty easy to set up; the server is a tad more involved and depends on your technology. For a Node-based solution, visit this page where Zach has a demo of it.

As I don't have a server that runs Node, I created my example using PHP. I started with an example that just sent a periodic message to the browser.

Example PHP code
<?php
// Set file mime type event-stream
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');

// Loop until the client close the stream
while (true) {

  if(connection_aborted()) exit();

  // Echo time
  $time = date('r');
  echo "data: The server time is: {$time}\n\n";

  // Flush buffer (force sending data to client)
  flush();

  // Wait 2 seconds for the next message / event
  sleep(2);
 }
?>

This example was ok, but missing discussion of sessions. To get to my final solution I had to first find this article. I had to disable sessions.

Disabling sessions in PHP
// make session read-only
session_start();
session_write_close();

Database Integration

If my friend is running a draft, he'd need a way to update the data. I decided to use MySQL and myPhpAdmin. The SSE server side code would now call the database instead of just incrementing a number. This was put into the loop so any DB updates would be created and sent as an event.

Data from the database
$sql = "SELECT * FROM messages\n"
    . "order by id desc\n"
    . "limit 1";

$result = $conn->query($sql);
if ($result->num_rows > 0) {
  if ($row = $result->fetch_assoc()) {
    $latestEventId = $row['id'];
    ...

As a way to update the data, I created a simple PHP page that would insert a record each time the page was refreshed.

Adding rows to our DB
<?php
$servername = "<your value>";
$username = "<your value>";
$password = "<your value>";
$dbname = "<your value>";

// Create connection
$conn = new mysqli($servername, $username, $password, $dbname);
// Check connection
if ($conn->connect_error) {
  die("Connection failed: " . $conn->connect_error);
}

$sql = "INSERT INTO messages () VALUES ()";

if ($conn->query($sql) === TRUE) {
  $data = "New record created successfully";
} else {
  $data =  "Error: " . $sql . "<br>" . $conn->error;
}

$conn->close();
?>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>

<section><?= $data ?></section>

<section>
  <button type="button" onclick="location.reload()">Reload</button> the page for a new entry
</section>


</body>
</html>

Conclusion

This was a pretty easy POC. Other concerns before this could be used would be the number of connections to the PHP server as well as the database connections.

Source Code

Landing page
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SSE</title>
</head>

<body>
  <h1>SSE demo with PHP</h1>

  <a href="db.php" target="_blank">Click here to open a window which will create a DB entry</a>
  <br />
  Each time a new entry is added to the database, an event is pushed to this page.
  <br />
  The link above can be refreshed to add a new event/record in the DB.

  <ol id="list">
  </ol>

  <script>
    // Create new event, the server script is sse.php
    var eventSource = new EventSource("sse.php");

    // Event when receiving a message from the server
    eventSource.onmessage = function (event) {
      // Append the message to the ordered list
      document.getElementById("list").innerHTML += '<li>' + event.data + "</li>";
    };
  </script>
</body>

</html>
SSE page
<?php

// Lots of code taken from https://kevinchoppin.dev/blog/server-sent-events-in-php

$servername = "<your value>";
$username = "<your value>";
$password = "<your value>";
$dbname = "<your value>";

try {
  // make session read-only
  session_start();
  session_write_close();

  // disable default disconnect checks
  ignore_user_abort(true);

  // set headers for stream
  header("Content-Type: text/event-stream");
  header("Cache-Control: no-cache");
  header("Access-Control-Allow-Origin: *");

  // Is this a new stream or an existing one?
  $lastEventId = floatval(isset($_SERVER["HTTP_LAST_EVENT_ID"]) ? $_SERVER["HTTP_LAST_EVENT_ID"] : 0);
  if ($lastEventId == 0) {
    $lastEventId = floatval(isset($_GET["lastEventId"]) ? $_GET["lastEventId"] : 0);
  }

  echo ":" . str_repeat(" ", 2048) . "\n"; // 2 kB padding for IE
  echo "retry: 2000\n";

  // start stream
  while (true) {
    if (connection_aborted()) {
      exit();
    } else {

      $conn = new mysqli($servername, $username, $password, $dbname);
      if ($conn->connect_error) {
        die("Connection failed: " . $conn->connect_error);
      }

      $sql = "SELECT * FROM messages\n"
        . "order by id desc\n"
        . "limit 1";

      $result = $conn->query($sql);

      if ($result->num_rows > 0) {
        if ($row = $result->fetch_assoc()) {
          $latestEventId = $row['id'];

          if ($lastEventId < $latestEventId) {
            echo "id: " . $latestEventId . "\n";
            $time = $row['timestamp'];
            echo "data: A record was recorded at: {$time}\n\n";

            $lastEventId = $latestEventId;
            ob_flush();
            flush();
          } else {
            // no new data to send
            echo ": heartbeat\n\n";
            ob_flush();
            flush();
          }
        }
      }
    }

    // 2 second sleep then carry on
    sleep(2);
  }
} catch (Exception $e) {
  echo 'Message: ' . $e->getMessage();
}

DB updating page
<?php
$servername = "<your value>";
$username = "<your value>";
$password = "<your value>";
$dbname = "<your value>";

$conn = new mysqli($servername, $username, $password, $dbname);
if ($conn->connect_error) {
  die("Connection failed: " . $conn->connect_error);
}

$sql = "INSERT INTO messages () VALUES ()";

if ($conn->query($sql) === TRUE) {
  $data = "New record created successfully";
} else {
  $data =  "Error: " . $sql . "<br>" . $conn->error;
}

$conn->close();
?>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>

<section><?= $data ?></section>

<section>
  <button type="button" onclick="location.reload()">Reload</button> the page for a new entry
</section>

  
</body>
</html>

Coming soon!

Comment on "Motion Canvas Fun"

Not displayed but the md5 hash is used for Gravatar image.