Server-Sent Events
Server-Sent Events (SSE) let a Jarvis service push a stream of events to a client over one long-lived HTTP response. In the browser, the EventSource API reads the stream, hands each event to your JavaScript, and reconnects by itself if the connection drops.
SSE suits one-way, server-to-client updates such as live dashboards, progress reports, notifications and feeds. The client sends requests to your service in the usual way. Compared with WebSockets, SSE is plain HTTP: it needs no protocol upgrade, works through most proxies, and uses Jarvis's existing validation, CORS, authentication and session handling.
SSE works in both the JSON and REST paradigms.
A first example
Name the endpoint in SSEEndpoints. An endpoint function is optional; here it sends a greeting to each new client:
⍝ in CodeLocation
∇ r←events req
{}req req.Server.SendSSE 'Hello!' ⍝ send to this client only
r←0 ⍝ 0 keeps the stream open
∇
j←Jarvis.New ''
j.CodeLocation←#
j.SSEEndpoints←'events'
j.Start
In a page served from the same Jarvis (for example with HTMLInterface):
const source = new EventSource("events");
source.onmessage = e => console.log(e.data); // "Hello!"
From then on, any APL code with a reference to the Jarvis instance can push events to the open streams:
(j.SSEConnections 'events') j.SendSSE 'Something happened'
To watch a raw stream, use curl -N http://localhost:8080/events.
How an SSE request is handled
A request whose endpoint is listed in SSEEndpoints goes through the same early steps as any other request:
- The request is received and its body decoded.
- Your
ValidateRequestFn, if any, is called. A non-zero result fails the request with 400 (Bad Request).
Then, instead of calling a JSON or REST handler, Jarvis checks the request:
- CORS. A CORS preflight (
OPTIONS) request gets its usual 204 (No Content) response. For a cross-originGET, theAccess-Control-Allow-Originheader is added. See CORS Settings. - The method must be
GET. Otherwise the request fails with 405 (Method Not Allowed). - The
Acceptheader, if present, must allowtext/event-stream(text/event-stream,text/*or*/*). Otherwise the request fails with 406 (Not Acceptable).EventSourcealways sendsAccept: text/event-stream. - Authentication. Your
AuthenticateFn, if any, is called; a non-zero result fails the request with 401 (Unauthorized). If you use sessions, the session is checked or created here.
If any of these steps fails, the client gets an ordinary error response and no stream is opened. With HTMLInterface enabled, that response has the usual short HTML error body.
If all the steps succeed, Jarvis starts the stream:
- It sends a
200 OKresponse header withContent-Type: text/event-stream; charset=utf-8,Cache-Control: no-cache,Connection: closeandX-Accel-Buffering: no(which tells nginx not to buffer the stream). Any headers that your hook functions set on the request's response (for example a session header) are sent too. - It sends a
: connectedcomment, so the client knows at once that the stream is open. - It calls the endpoint function, if there is one (see below).
The stream has no length. It stays open until the client disconnects, your code closes it, or the server stops. It works with both HTTP/1.0 and HTTP/1.1 clients.
Some features for ordinary requests don't apply to SSE streams:
PostProcessFnisn't called.- The stream isn't compressed, even if
UseZipis set. ConnectionTimeoutdoesn't apply: an idle stream isn't closed. UseSSEHeartbeatIntervalto find clients that have gone away.
The Request object's IsSSE field is 1 for a request to an SSE endpoint, so an AuthenticateFn can treat streams differently from other requests. IsSSE is set after ValidateRequestFn is called, so it's still 0 there; a ValidateRequestFn can check req.Endpoint instead.
The endpoint function
An SSE endpoint doesn't need a function. Without one, the stream simply opens, and your application sends events to it later.
If CodeLocation has a function with the endpoint's name, Jarvis calls it once, after the stream has started. The function must be monadic or ambivalent. Its right argument is the Request object, which we'll call req. Useful parts of req are:
| -- | -- |
req.Server |
The Jarvis instance, for calling SendSSE, SSEConnections and FormatSSE. |
req.Connection |
The name of this client's connection. You can keep it and send to this client later. |
req.GetHeader 'last-event-id' |
The id of the last event the client received, if it's reconnecting (see Reconnecting). |
req.QueryParams |
Any query parameters, for example from new EventSource("events?topic=prices"). |
req.Session |
The session, if you use sessions. |
The function's result decides what happens to the stream:
| Result | Effect |
|---|---|
0, or no result |
The stream stays open. |
| any other value | Jarvis closes the stream. |
| an APL error | Jarvis logs Error in SSE handler for endpoint … and closes the stream. |
A shy result is treated like an explicit one.
The function can return straight away, as in the example above, leaving other code to send events later. Or it can loop, sending events to its own client until it's done:
∇ r←progress req;i;rc
:For i :In ⍳10
⎕DL 1
:If 0≠rc←req req.Server.SendSSE ('progress' i) req.Server.FormatSSE i×10
:Leave ⍝ the client has gone away
:EndIf
:EndFor
r←1 ⍝ done: close the stream
∇
The function runs on the thread that handled the request, so a looping function doesn't block other requests. If the client disconnects while it's running:
- By default, the function keeps running until its next
SendSSEfails. CheckSendSSE's result, as above. - If the function sets
req.KillOnDisconnect←1,Jarviskills its thread as soon as it sees the disconnect.
With Debug set to 2, Jarvis stops just before calling the endpoint function, as it does for other endpoints.
Sending events
SendSSE
| Description | SendSSE sends an event to one or more open SSE streams. |
| Syntax | {r}←targets j.SendSSE payload |
targets |
One or more streams to send to. Each target can be either:
targets is empty, nothing is sent and r is ⍬. |
payload |
What to send:
⎕UCS 65279) is removed, and the text is sent as UTF-8. |
r |
One number per target, in order:
|
| Examples | req req.Server.SendSSE 'Welcome!' ⍝ to one client, from its endpoint function(j.SSEConnections 'prices') j.SendSSE 'price' j.FormatSSE quote ⍝ broadcast(j.SSEConnections '') j.SendSSE '' ⍝ keep-alive comment to every stream |
| Notes |
|
SSEConnections
| Description | SSEConnections returns the names of the open SSE connections, either all of them or those for particular endpoints. |
| Syntax | r←j.SSEConnections endpoints |
endpoints |
One of:
|
r |
A vector of connection names, suitable as the left argument to SendSSE. A name that isn't an SSE endpoint matches no connections. |
| Examples | (j.SSEConnections 'prices') j.SendSSE 'price' j.FormatSSE quote≢j.SSEConnections '' ⍝ number of open streams |
| Notes |
|
FormatSSE
| Description | FormatSSE formats one Server-Sent Event, ready to send with SendSSE. It's a shared method, so you can call it as Jarvis.FormatSSE or through an instance (j.FormatSSE, req.Server.FormatSSE). |
| Syntax | r←{fields} Jarvis.FormatSSE data |
data |
The event's data: lines. One of:
|
fields |
[optional] The event's other fields. One of:
retry value that isn't a non-negative integer. Line breaks are removed from field values. |
r |
One complete event: a character vector of SSE lines, ending with a blank line. If there are no fields and no data, r is a : comment, which is useful as a keep-alive. |
| Examples | Jarvis.FormatSSE 'hello' |
| Notes |
|
IsSSEText
| Description | IsSSEText reports whether text is made up of one or more complete SSE events. SendSSE uses it to decide whether a payload needs formatting. It's a shared method. |
| Syntax | r←Jarvis.IsSSEText text |
text |
The text to check. |
r |
1 if text is a simple character vector made up of one or more complete SSE events, otherwise 0. To count as complete events:
|
| Examples | Jarvis.IsSSEText 'data: hello',⎕UCS 10 10 ⍝ 1Jarvis.IsSSEText 'data: hello' ⍝ 0: no blank line at the endJarvis.IsSSEText 'foo: bar',⎕UCS 10 10 ⍝ 0: unknown field |
| Notes |
|
Heartbeats
Every SSEHeartbeatInterval seconds (30 by default), Jarvis sends a : comment to every open SSE stream. Heartbeats keep proxies from closing streams that are idle, and they find clients that have disconnected without Jarvis noticing: if a heartbeat can't be sent, that connection is closed. Browsers don't pass comments to the page.
Set SSEHeartbeatInterval←0 to turn heartbeats off. You can still send your own keep-alives with targets j.SendSSE ''.
Reconnecting and Last-Event-ID
If a stream drops, for example because the network fails or the server restarts, EventSource reconnects by itself after a few seconds. You can set the delay, in milliseconds, with an event's retry field.
When it reconnects, the browser sends a Last-Event-ID header with the id of the last event it received, if any. Your endpoint function can read it with req.GetHeader 'last-event-id' and send the events the client missed. Jarvis doesn't keep past events: what to replay, and where to keep the events and ids, is up to your application. If the ids must stay valid across server restarts, keep them somewhere outside the workspace.
EventSource doesn't reconnect after a response that isn't 200 with Content-Type: text/event-stream. For example, a rejected request (401, 405 and so on) stops it for good. Closing the stream from the server (with a non-zero result from the endpoint function) is not an error, so the browser reconnects. To stop a client from reconnecting, call source.close() in the page, for example when it receives an event you've named for this purpose.
Authentication and sessions
EventSource can't add headers to its request. So a browser client can't send an Authorization header or a session id header such as the one named by SessionIdHeader. Options include:
- Cookies, which the browser sends automatically. For sessions, set
SessionUseCookie←1. For a cross-origin stream, create theEventSourcewith{withCredentials: true}, and setCORS_Originto the page's origin (or1) rather than'*'. - A token in the query string (
events?token=…), checked in yourAuthenticateFnwithreq.QueryParams. Remember that URLs tend to be logged.
Clients other than browsers (for example curl or an APL HTTP client) can send any headers.
Things to bear in mind
- Each open stream is a connection that stays open. Over HTTP/1.1, browsers allow only about six connections to the same server, shared by every tab. If a user opens many tabs, each with its own stream, other requests to your service can stall. Use one stream per page, with named events for different kinds of updates.
- Conga supports HTTP/1.x only, so the HTTP/2 multiplexing that avoids this limit isn't available.
- Some proxies buffer responses, which delays events.
JarvissendsX-Accel-Buffering: nofor nginx; other proxies may need their own configuration. Jarvisdoesn't queue events for slow clients. EachSendSSEhands the event to Conga at once.
Sample
Samples/SSE in the Jarvis repository is a complete example: a page showing a live server clock and a shared chat. It shows:
- an endpoint function that greets each client and reads
Last-Event-ID - a thread started by
AppInitFnthat broadcasts a namedtickevent every second - an ordinary JSON endpoint that broadcasts
chatevents to every stream
See the sample's README.md for how to run it.