2021年6月23日 星期三

[sse, express, react] How to create server streaming events to the front-end client using Express and React

How to create server streaming events to the front-end client using Express and React

井民全, Jing, mqing@gmail.com



Fig. Server sends events to the frontend client. (edit)


In the traditional way, the scenario is a browser pulls server resources by sending the Requests. However, what if, we want to develop a web app that the server can send your browser unlimited amounts of information until you leave that page. The web app opens a connection to the server, listening to the message from it, renders the update. Here I'll show you how to do.


The answer is server-sent-event[1]. 




GitHub


Quick


Note

GET xxxx ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)

==> 

Solution from here.

-- 

The most likely cause of the eventsource requests failing is that they are timing out. The Chrome browser will kill an inactive stream after two minutes of inactivity. If you want to keep it alive, you need to add some code to send something from the server to the browser at least once every two minutes. Just to be safe, it is probably best to send something every minute. An example of what you need is below. It should do what you need if you add it after your second setTimeout in your server code.

const intervalId = setInterval(() => {

  res.write(`data: keep connection alive\n\n`);

  res.flush();

}, 60 * 1000);


req.on('close', () => {

  // Make sure to clean up after yourself when the connection is closed

  clearInterval(intervalId);

});




Express

File: xxx.ts (in Express middle function)

   // Step 1: Setup the header

    res.setHeader('Cache-Control', 'no-cache');

    res.setHeader('Content-Type', 'text/event-stream');

    // res.setHeader('Access-Control-Allow-Origin', '*'); // Allow any origin server to request resource

    res.setHeader('Connection', 'keep-alive'); 

    res.flushHeaders(); // flush the headers to establish SSE with client


    

    // Step 2: Send data to browser

    res.write(`data: ${JSON.stringify({num: 123})}\n\n`); 

    

    // Step 3(a): Close connection from server side

    if(...)

          res.end(); // terminates SSE session


    // Step 3(b): Close connection from client side (browser)

    res.on('close', () => {

        console.log('client dropped me');

        res.end();

    });


React Client

File: xxx.tsx (in React component)

// Step 1: Create connection

const url = 'statusAPI'; 

const evtSource = new EventSource(url);


// Step 2: Receiving events from the server

evtSource.onmessage = function(event) {

      // Note: I customized the data format sent from server:   {num: value}

      const strNum:string = JSON.parse(event.data).num// parsing the data

      let num:number = parseInt(strNum);

      console.log('num = ' + num);


// Step 3: Asking server stop pushing data

     evtSource.close();

}


Table of contents

1. Tree

2. The server side (Express)

2.1. Code

3. The client side (React)

3.1. Connect to the server

3.2. Receiving events from the server

3.3. Stop

3.4. Code

4. Test

4.1. Install, Build, Run

4.2. Result

4.3. Make clean

4.3.1. Clean script: Server

4.3.2. Clean script: Client

5. References

6. Further Reading


1. Tree

Server part

./server

├── index.ts

├── package-lock.json

├── package.json

├── routes

│   └── statusAPI.ts

├── tsconfig.json

└── yarn.lock

Client part

./client

├── README.md

├── build

├── package.json

├── public

├── src

│   ├── App.css

│   ├── App.test.tsx

│   ├── App.tsx

│   ├── component

│   │   ├── my-image.tsx

│   │   └── res

│   │       ├── greenlight-v3.png

│   │       ├── redlight-v3.png

│   │       └── yellowlight-v3.png

│   ├── index.css

│   ├── index.tsx

│   ├── ...

├── tsconfig.json

└── yarn.lock



2. The server side (Express)

On the server side, just respond to the MIME content type as text/event-stream and use the "write" method to send the UTF-8 based string to the client side. Remember that, by default,  if the connection between the client and server closes, the connection is restarted. The connection is terminated with the .close() method [ref].

  1. Content-Type: text/event-stream

  2. res.write

Ex:

   // Step 1: Setup the header

    res.setHeader('Cache-Control', 'no-cache');

    res.setHeader('Content-Type', 'text/event-stream');

    // res.setHeader('Access-Control-Allow-Origin', '*'); // Allow any origin server to request resource

    res.setHeader('Connection', 'keep-alive'); 

    res.flushHeaders(); // flush the headers to establish SSE with client


    

    // Step 2: Send data to browser

    res.write(`data: ${JSON.stringify({num: 123})}\n\n`); 

    

    // Step 3(a): Close connection from server side

    if(...)

          res.end(); // terminates SSE session


    // Step 3(b): Close connection from client side (browser)

    res.on('close', () => {

        console.log('client dropped me');

        res.end();

    });



2.1. Code

The Express server with statusAPI code was listed as following.

File: server/index.ts

import express from 'express';

import path from 'path';


var statusAPIRouter = require('./routes/statusAPI');

const ReactBuildPath:string = '../client/build';


const app = express();


// Register a middleware to serve files from the React production build folder: ../build

app.use(express.static(path.join(__dirname, ReactBuildPath)));


// Register a router statusAPIRouter on the virtual path /statusAPI. 

app.use('/statusAPI', statusAPIRouter);


// Register a middleware that handle GET request on the '/' to response React production index.html

app.get('/', function (req, res) {

  res.sendFile(path.join(__dirname, ReactBuildPath + '/index.html'));

});


app.listen(9000);

console.log('Server: ok')

console.log('Test home: gio open http://localhost:9000/')

console.log('Test API: gio open http://localhost:9000/statusAPI')


File: routes/statusAPI.js

import express, {Request, Response} from 'express';


var router = express.Router();

router.get('/', function(req:Request, res:Response, next) {

     // Step 1: Setup the header

    res.setHeader('Cache-Control', 'no-cache');

    res.setHeader('Content-Type', 'text/event-stream');

    // Allow any origin server to request resource

    // res.setHeader('Access-Control-Allow-Origin', '*');


    // Keep-alive connections allow the client and server to use the same TCP connection to
    // send and receive multiple HTTP requests and responses.

    res.setHeader('Connection', 'keep-alive'); 

    res.flushHeaders(); // flush the headers to establish SSE with client


    let counter = 0;

    let interValID = setInterval(() => {

        counter++;

        if (counter >= 10) {

            clearInterval(interValID);

            // Step 3(a): Close connection from server side

            res.end(); // terminates SSE session

            return;

        }


        // Step 2: Send data to browser

        res.write(`data: ${JSON.stringify({num: counter})}\n\n`); 

    }, 1000);


    // If client closes connection, stop sending events

    res.on('close', () => {

        console.log('client dropped me');

        clearInterval(interValID);

        res.end();

        console.log('res.end()');

    });

});


module.exports = router;


3. The client side (React)

3.1. Connect to the server

Use EventSource to create a connection to the server and then listen the message to get the data that pushed from server.

// Step 1: create connection

 // (a) if the event generator script is hosted on a the same origin

const url = 'statusAPI'; 

// (b) if the generator located at different origin

// const url = 'http://192.168.1.100:9000/statusAPI'; 


const evtSource = new EventSource(url);


3.2. Receiving events from the server

// Step 2: Receiving events from the server

evtSource.onmessage = function(event) {

       // Note: I customized the data format sent from server:   {num: value}

      const strNum:string = JSON.parse(event.data).num// parsing the data

      let num:number = parseInt(strNum);

      console.log('num = ' + num);

}


By default, if the connection between the client and server closes, the connection is restarted. The connection is terminated with the .close() method[2].

3.3. Stop

// Step 3: Asking server stop pushing data

evtSource.close();



3.4. Code

File: src/App.tsx

import React, { useState } from 'react';

import './App.css';

import Button from '@material-ui/core/Button';

import MyImage from './component/my-image';


function App() {

  // Create the Ref to your component

  let myimage = React.createRef<MyImage>();

  

  const onStartSentEventFromServer = async () => {

    console.log('onStartSentEventFromServer')

    // const url = 'http://192.168.1.100:9000/statusAPI';  // ok

    const url = 'statusAPI'; // ok 

    //const evtSource = new EventSource(url, { withCredentials: true } ); // ok


    // Step 1: Create connection

    const evtSource = new EventSource(url); // ok


    // Step 2: Receiving events from the server

    evtSource.onmessage = function(event) {

      const strNum:string = JSON.parse(event.data).num;

      let num:number = parseInt(strNum);

      console.log('num = ' + num);

      (myimage.current as MyImage).changeLight(num%2);


      /* By default

          if the connection between the client and server closes, the connection is restarted.
        The connection is terminated with the .close() method.

      */

      if (num == 5){

      // Step 3: Asking server stop pushing data

        console.log('[Early Stop] entSource.close')

        evtSource.close();

      }

    }

  }


  return (

    <div className="App">

      <header className="App-header">

        <p>

          <MyImage ref = {myimage} /> 

          <Button onClick={onStartSentEventFromServer} variant="contained" color="primary"> Start to received event from Server </Button>

        </p>

      </header>

    </div>

  );

}


export default App;




 

For the image component, I borrow the code from here ( [react, component, image] How to switch the image when user clicked the button) that can switch the image based on the method chageLight.

File: src/component/my-image.tsx

import React from 'react';


import redlight from './res/redlight-v3.png'

import greenlight from './res/greenlight-v3.png'


class MyImage extends React.Component {

    RED_LIGHT = {id:0, strName:'red light', strLoc: redlight};

    GREEN_LIGHT = {id:1, strName:'green light', strLoc: greenlight};


    state = {

      data : this.RED_LIGHT,

    }


    public changeLight = (_id:number) => {

      switch(_id){

        case 0:

          this.setState({data : this.RED_LIGHT});

          break;

        case 1: 

          this.setState({data : this.GREEN_LIGHT});

          break;

        default:

          alert('Invalid _id, changeLight, MyImage. _id = . Act: use Red Light as default' + _id);

          this.setState({data : this.RED_LIGHT});

      }

    };


    render() {

      return (

        <div>

          <img src={this.state.data.strLoc} ></img>

        </div>

      );

    }

}


export default MyImage;


4. Test

4.1. Install, Build, Run

# install

yarn --cwd ./server install

yarn --cwd ./client install

yarn --cwd ./client build


# run

pushd ./server && tsc && popd && node ./server/index.js


4.2. Result

https://youtu.be/gr56-WMfyyw


4.3. Make clean


yarn --cwd ./server clean

yarn --cwd ./client clean



4.3.1. Clean script: Server

File: ./server/package.json

"clean": "rm -fr node_modules; find . -name \"*.js\" -type f|xargs rm -f; find . -name \"*.map\" -type f|xargs rm -f"

4.3.2. Clean script: Client

"clean": "(rm -fr node_modules; rm -fr build; find . -name \"*.js\" -type f|xargs rm -f; find . -name \"*.map\" -type f|xargs rm -f)"


5. References

  1. Server-sent events, https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events

  2. Using server-sent events, https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events

  3. Building with server-sent events with React and Node.js, https://dev.to/4shub/building-with-server-sent-events-13j

  4. Learning about the HTTP “Connection: keep-alive” header, https://blog.insightdatascience.com/learning-about-the-http-connection-keep-alive-header-7ebe0efa209d


6. Further Reading

  1. [react, component, create] How to create a React component using class**** (view)

  2. [react, component, image] How to add a static asset to the React component (view), (blog)

  3. [react, component, image] How to switch the image when user clicked the button**** (view), (blog)