Skip to content

Latest commit

 

History

History
397 lines (288 loc) · 14.3 KB

README.md

File metadata and controls

397 lines (288 loc) · 14.3 KB

Server-Sent Events ✅

NPM Latest Version Coverage Status NPM Monthly Downloads Dependencies

Class Based implementation for Server-Sent events

Table of Contents

Getting started

Server-Side

Streaming events data

With server-sent events, it's possible for a server to send new data to a web page at any time, by pushing messages to the web page. These incoming messages can be treated as Events + data inside the web page.

By creating a new instance of ServerSentEvents class you will get access to methods required to stream data to the client.

Simple return ServerSentEvents.stream.readable in a Response instance to start streaming data.
⚠️ Do not forget to return the proper response headers.
⚠️ The streaming Response implementation depends on your back-end application.

import { ServerSentEvents } from '@alessiofrittoli/server-sent-events'

const sse = new ServerSentEvents()

return (
  new Response( sse.stream.readable, { headers: sse.headers } )
)
Writing into the stream

By using the method ServerSentEvents.write() you can write and push any serializeable data to the client.
This method will serialize your data before sending it to the client by using JSON.stringify().
The data is then read by the client by listening to the default message event on the EventSource instance (See Reading server-sent events data for more information about reading received data).

For this example we are going to execute a function which simulates an asynchronous long task.

import { ServerSentEvents } from '@alessiofrittoli/server-sent-events'

export const sleep = ( ms: number ) => (
  new Promise<void>( resolve => setTimeout( resolve, ms ) )
)

const longRunningTask = async ( stream: ServerSentEvents ) => {
  await stream.write( { message: 'Started' } )
  await sleep( 1000 )
  await stream.write( { message: 'Done 15%' } )
  await sleep( 1000 )
  await stream.write( { message: 'Done 35%' } )
  await sleep( 1000 )
  await stream.write( { message: 'Done 75%' } )
  await sleep( 1000 )
  await stream.write( { message: 'Final data' } )
}


const businessLogic = () => {

  const sse = new ServerSentEvents()

  longRunningTask( sse )

  return (
      new Response( sse.stream.readable )
  )

}
Custom events

By default our data is implicitly sent over the default message event.

However, you may need to push new data into the same connection stream under a custom event.
We can then specify its name as 2nd argument of the ServerSentEvents.write() method like so:

...

sse.write( { message: 'My data' }, 'customEvent' )

...

Notice that the client should now add a new listener to the EventSource instance referring to the name of the custom event.
Please refer to the Reading server-sent custom events data section for more information.

Closing the stream

The EventSource API has a built-in automatic reconnection mechanism. If the connection to the server is lost (due to network issues, server restart, etc.), the EventSource will try to reconnect after a delay if the client is not explicitly calling the EventSource.close() method. By default, this delay is 3 seconds, see Reconnection policies section for more information.

Due to the EventSource automatic recconnection mechanism the previous code will eventually end up in a sort of an infinite loop.

To tell the client that streaming is complete we can execute the ServerSentEvents.close() method. This method will push a custom event named "end" in the stream and close the ServerSentEvents.writer (See Custom events section to learn more about custom events).
⚠️ The client should listen for the "end" event and then close the EventSource connection with EventSource.close().

Since our previous function returns a void Promise, we can await it and then call the ServerSentEvents.close() method like so:

...

const businessLogic = () => {

  const sse = new ServerSentEvents()

  longRunningTask( sse )
      .then( () => {
          console.log( 'Streaming done.' )
          sse.close()
      } )

  return (
      new Response( sse.stream.readable )
  )

}
Reconnection policies

The EventSource API has a built-in automatic reconnection mechanism. If the connection to the server is lost (due to network issues, server restart, etc.), the EventSource will try to reconnect after a delay if the client is not explicitly calling the EventSource.close() method. By default, this delay is 3 seconds but the server can override this timing by setting the "retry" policy.

When creating a new instance of ServerSentEvents we can specify the reconnection delay value in milliseconds to the "retry" property of the constructor like so:

const sse = new ServerSentEvents( { retry: 5000 } )
Error handling

If an error occures in our longRunningTask example function we can use the ServerSentEvents.error() method to push a custom error event to the stream. The client should listen the default error event on the EventSource to handle errors client side.

Good to know - Since the ServerSentEvents.error() will push an event with the name error, the client could use a single listener to listen default and custom errors (See EventSource Error handling section to learn more about error handling on client).

...

const businessLogic = () => {

  ...

  longRunningTask( sse )
      .then( () => {
          ...
      } )
      .catch( error => {
          console.error( 'Failed', error )
          sse.error( { message: error.message } )
      } )

  ...

}
Abort handling

Sometimes, error handling or awaiting a task to be finished before closing the stream could be not enough. Let's assume your task is an infinite task (like returning the time once a second) and the user abort the EventSource request by closing the client or by calling the EventSource.close() method: your infinite task will be still running.

Most back-end server applications will allow you to listen for an abort signal that will be fired when the request has been aborted by the client by forcibly shutting down the connection or by calling the EventSource.close() method. By listening for an abort signal, we can then execute the ServerSentEvents.abort() which will abort the ServerSentEvents.writer and prevent subsequent write events by setting the ServerSentEvents.closed flag to true.

For this example, we are going to desing a function that will push to the stream the current date in a ISO string format once a second. In our interval we check if ServerSentEvents.closed has been set to true before pushing new data into the stream and resolve the Promise if so.

...

const timer = async ( stream: ServerSentEvents ) => {
  await stream.write( { message: new Date().toISOString() } )
  await new Promise<void>( resolve => {
    const interval = setInterval( () => {
      if ( stream.closed ) {
        clearInterval( interval )
        return resolve()
      }
      stream.write( { message: new Date().toISOString() } )
    }, 1000 )
  } )
}

const businessLogic = request => {

  ...

  request.signal.addEventListener( 'abort', event => {
    sse.abort( 'Request has been aborted from user.' )
  } )

  timer( sse )
    .then( () => {
        ...
    } )
    .catch( error => {
        console.error( 'Failed', error )
        if ( error.name === 'AbortError' ) return
        sse.error( { message: error.message } )
    } )

  ...

}

If you do not check if ServerSentEvents.closed is true before pushing new data, the ServerSentEvents.write() method will throw a DOMException with the given AbortError reason.

...

const timer = async ( stream: ServerSentEvents ) => {
  await stream.write( { message: new Date().toISOString() } )
  await new Promise<void>( resolve => {
    const interval = setInterval( () => {
      stream.write( { message: new Date().toISOString() } )
        .catch( error => {
          clearInterval( interval )
          return reject( error )
          } )
    }, 1000 )
  } )
}

const businessLogic = request => {

  ...

  request.signal.addEventListener( 'abort', event => {
    sse.abort( 'Request has been aborted from user.' )
  } )

  timer( sse )
      .then( () => {
        ...
      } )
      .catch( error => {
        console.error( 'Failed', error )
        if ( error.name === 'AbortError' ) {
          return console.log( 'Streaming stopped:', error.message )
        }
        sse.error( { message: error.message } )
      } )

  ...

}

Client-Side

Reading server-sent events data

To listen server-sent events we can use the native EventSource Web API.
The connection remains open until closed by calling EventSource.close().

Once the connection is opened, incoming messages from the server are delivered to your code in the form of events. If there is an event field in the incoming message, the triggered event is the same as the event field value. If no event field is present, then a generic message event is fired.

const eventSource = new EventSource( new URL( ... ) )

eventSource.addEventListener( 'open', event => {
  console.log( 'Connection opened', event )
} )

eventSource.addEventListener( 'message', event => {
  const data = JSON.parse( event.data )
  console.log( '"message" event received data', data )
} )
Reading server-sent custom events data

To listen for incoming messages from the server sent over a custom event, we just add an event listener on the EventSource instance like we've done in the previous example.

...

eventSource.addEventListener( 'customEvent', event => {
  const data = JSON.parse( event.data )
  console.log( '"customEvent" event received data', data )
} )
Closing the EventSource

We can call the EventSource.close() method arbitrarily or listen to the "end" event sent by the server to close the EventSource connection.

...

const cancelRequestButton = document.querySelector( '#cancel' )

cancelRequestButton.addEventListener( 'click', e vent=> {
  eventSource.close()
  console.log( 'User aborted the request.', eventSource.readyState )
} )
...

eventSource.addEventListener( 'end', event => {
  eventSource.close()
  console.log( '"end" event received', eventSource.readyState, event )
} )
EventSource Error handling

By default, errors are handled by listening to the "error" event on the EventSource instance.
The server may use this event too to return handled errors occured on the server while running its tasks.

eventSource.addEventListener( 'error', event => {
  eventSource.close()
  console.log( '"error" event', eventSource.readyState, event )
} )

Contributing

Contributions are truly welcome!
Please refer to the Contributing Doc for more information on how to start contributing to this project.


Security

If you believe you have found a security vulnerability, we encourage you to responsibly disclose this and NOT open a public issue. We will investigate all legitimate reports. Email [email protected] to disclose any security vulnerabilities.

Made with ☕

avatar
Alessio Frittoli
https://alessiofrittoli.it | [email protected]