EventSource And Generating Server-Sent Events In ColdFusion / Lucee CFML
Many years ago, I took at look at the long-polling technique in ColdFusion. Long-polling creates a persistent HTTP connection that blocks-and-waits for data to be sent down over the network to the client. Eventually, this pattern became codified within the browser's native functionality using EventSource
. I've never actually played with the EventSource
object; so, I thought it would be fun to put together a simple ColdFusion demo.
Long Polling Is What Exactly?
As a preface to this, I wanted to mention that I came across a number of articles that did not classify the EventSource
technique as a "long polling" technique. In those articles, they defined long polling as a blocking request that waited for a single message to arrive before terminating the connection and then reconnecting to the server.
That said, I don't believe that long polling ever had to be that narrow in scope. I've always viewed long polling as a blanket term that covers any technique backed by a persistent uni-directional HTTP request. In fact, if you look at my "long polling" demo from 12-years ago, the serve-side ColdFusion code is almost identical to what I have in my EventSource
demo (if you ignore the modernized CFML syntax).
Suffice to say, I see EventSource
as a "long polling" technique that is much, much easier to implement now that the bulk of the logic is handled by native browser code.
EventSource
And Server-Sent Events in ColdFusion
Using To experiment with EventSource
and server-sent events, I'm going to maintain a "blocking queue" in my ColdFusion application scope. Then, I'll create one ColdFusion page that pushes messages onto said queue (using traditional POST
-backs to the server); and, I'll create another ColdFusion page that receives messages from that queue via EventSource
long polling.
My Application.cfc
does nothing but create an instance of LinkedBlockingQueue
to hold our messages. I decided to use this concurrent class instead of a native ColdFusion array so that the blocking aspect would be managed by lower-level Java code.
component
output = false
hint = "I define the application settings and event handlers."
{
// Define the application settings.
this.name = "ServerSentEvents";
this.applicationTimeout = createTimeSpan( 1, 0, 0, 0 );
this.sessionTimeout = false;
this.setClientCookies = false;
// ---
// LIFE-CYCLE EVENTS.
// ---
/**
* I run once to initialize the application state.
*/
public void function onApplicationStart() {
// The LinkedBlockingQueue is really just a fancy Array that has a .poll() method
// that will block the current request and wait for an item (for a given timeout)
// to become available. This will be handy in our EventSource target.
application.messageQueue = createObject( "java", "java.util.concurrent.LinkedBlockingQueue" )
.init()
;
}
}
With this messageQueue
in place, I created a very simple ColdFusion page that has an <input>
for a message and pushes that message onto the queue with each FORM
post:
<cfscript>
param name="form.message" type="string" default="";
// If a message is available, push it onto the queue for our EventSource / server-sent
// event stream.
if ( form.message.len() ) {
application.messageQueue.put({
id: createUniqueId(),
text: form.message.trim(),
createdBy: "Ben Nadel"
});
}
</cfscript>
<cfoutput>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="./styles.css"></link>
</head>
<body>
<h1>
Send a Message to the Queue
</h1>
<form method="post">
<input
type="text"
name="message"
placeholder="Message..."
size="50"
/>
<button type="submit">
Send to Queue
</button>
</form>
<script type="text/javascript">
// For some reason, the "autofocus" attribute wasn't working consistently
// inside the frameset.
document.querySelector( "input" ).focus();
</script>
</body>
</html>
</cfoutput>
Pushing messages onto the queue isn't very interesting; but, things get a little more provocative when we start popping messages off of that queue and pushing them down over the network to the browser. When we create a server-sent event stream in ColdFusion, we have to adhere to a few implementation details:
The HTTP header,
Content-Type
, on the ColdFusion response has to betext/event-stream
.When pushing server-sent events, each line of the event data must be prefixed with,
data:
. And, each data line must end in a newline character.Individual messages must be delimited by two successive new-line characters.
We can optionally include a line prefixed with
event:
, for custom named events. By default, the event emitted in the client-side JavaScript ismessage
. However, if we include anevent:
line in our server-sent payload, the value we provide will then become the event-type emitted on theEventSource
instance.We can optionally include a line prefixed with
id:
, for a custom ID value.
By default, my CommandBox Lucee CFML server has a request timeout of 30-seconds. As such, I'm only going to poll the messageQueue
for about 20-seconds, after which the ColdFusion response will naturally terminate and the client-side EventSource
instance will attempt to reconnect to the ColdFusion API end-point.
In my code, I'm only blocking-and-polling the messageQueue
for 1-second at a time. There's no technical reason that I can't poll for a longer duration; other than the fact that I want to close-out the overall ColdFusion request in under 30-seconds. As such, by using a 1-second polling duration, it simply allows me to exercise more precise control over when I stop polling.
In the following server-sent event experiment, I'm providing the optional event:
and id:
lines as well as the required data:
line. In this case, my event will be called, cfmlMessage
.
<cfscript>
// Reset the output buffer and report the necessary content-type for EventSource.
content
type = "text/event-stream; charset=utf-8"
;
// By default, this Lucee CFML page a request timeout of 30-seconds. As such, let's
// stop polling the queue before the request times-out.
stopPollingAt = ( getTickCount() + ( 25 * 1000 ) );
newline = chr( 10 );
TimeUnit = createObject( "java", "java.util.concurrent.TimeUnit" );
while ( getTickCount() < stopPollingAt ) {
// The LinkedBlockingQueue will block-and-wait for a new message. In this case,
// I'm just blocking for up to 1-second since we're inside a while-loop that will
// quickly re-enter the polling.
message = application.messageQueue.poll( 1, TimeUnit.Seconds );
if ( isNull( message ) ) {
continue;
}
// By default, the EventSource uses "message" events. However, we're going to
// provide a custom name for our event, "cfmlMessage". This will be the type of
// event-listener that our client-side code will bind-to.
echo( "event: cfmlMessage" & newline )
echo( "id: #message.id#" & newline );
echo( "data: " & serializeJson( message ) & newline );
// Send an additional newline to denote the end-of-message.
echo( newline );
flush;
}
</cfscript>
The final piece of the puzzle is instantiating the EventSource
object on the client-side so that we can start long polling our server-sent events API end-point. In the following code, I'm taking the cfmlMessage
events sent by the ColdFusion server - and emitted by the EventSource
instance - and I'm merging them into the DOM (Document Object Model) by way of a cloned <template>
fragment.
Now, I should say that there is nothing inherent to the EventSource
object or to server-sent events that requires the use of JSON (JavaScript Object Notation). However, since I am encoding my message as JSON on the ColdFusion server, I then have to JSON.parse()
the payload in the JavaScript context in order to extract the message text.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="./styles.css"></link>
</head>
<body>
<h1>
Read a Message From the Queue
</h1>
<ul class="messages">
<!-- To be cloned and populated dynamically. -->
<template>
<li>
<strong><!-- Author. --></strong>:
<span><!-- Message. --></span>
</li>
</template>
</ul>
<script type="text/javascript">
var messagesNode = document.querySelector( ".messages" );
var messageTemplate = messagesNode.querySelector( "template" );
// Configure our event source stream to point to the ColdFusion API end-point.
var eventStream = new EventSource( "./messages.cfm" );
// NOTE: The default / unnamed event type is "message". This would be used if we
// didn't provide an explicit event type in the server-sent event in ColdFusion.
eventStream.addEventListener( "cfmlMessage", handleMessage );
eventStream.addEventListener( "error", handleError );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
/**
* I handle a message event on the EventSource.
*/
function handleMessage( event ) {
console.group( "CFML Message Event" );
console.log( "Type:", event.type );
console.log( "Last Event Id:", event.lastEventId );
console.log( "Data:", event.data );
console.groupEnd();
// The payload does NOT HAVE TO BE JSON. We happened to encode the payload as
// JSON on the server; which is why we have to parse it from JSON here.
var payload = JSON.parse( event.data );
// Populate our cloned template with the message data.
var li = messageTemplate.content.cloneNode( true );
li.querySelector( "strong" ).textContent = payload.createdBy;
li.querySelector( "span" ).textContent = payload.text;
messagesNode.prepend( li );
}
/**
* I handle an error event on the EventSource. This is due to a network failure
* (such as when the ColdFusion end-point terminates the request). In such cases,
* the EventSource will automatically reconnect after a brief period.
*/
function handleError( event ) {
console.group( "Event Source Error" );
console.log( "Connecting:", ( event.eventPhase === EventSource.CONNECTING ) );
console.log( "Open:", ( event.eventPhase === EventSource.OPEN ) );
console.log( "Closed:", ( event.eventPhase === EventSource.CLOSED ) );
console.groupEnd();
}
</script>
</body>
</html>
If we now run the send.cfm
and the read.cfm
ColdFusion templates side-by-side (via a frameset), we can see that data pushed onto the messages queue in one frame is - more or less - made instantly available in the EventSource
event stream in the other frame:
And, if we look at the messages.cfm
HTTP response in Chrome's network activity, we can see that a single, persistent HTTP request received all of the server-sent events that were emitting on the client-side:
While long polling is a very old technique, it's nice to see how simple the browser technologies have made it. I don't personally think it holds any significant benefit over using WebSockets, other than relative simplicity. But, I can definitely see this being useful in simple ColdFusion scenarios or prototypes where you want to demonstrate a value without having to have too much infrastructure behind it.
Want to use code from this post? Check out the license.
Reader Comments
Ben, helpful info. In using poll() method it is my understanding that actually removes the item from the head of the queue, which means in a multi-user/requestor system only one requestor would get the message. i.e. if 5 different people are connected to the event stream, only one of them will get the event. Have you considered any solutions that would work with multiple users at once?
@Jeff,
Yeah, delivering to multiple users gets more complicated. And, I'll admit that I don't have a lot of experience. I believe that you really have to know ahead of time which users should receive the message. And then, figure out a way to deliver the message to each of those people
At work, we accomplish this using Pusher (WebSocket SaaS product). All users subscribe to a Pusher channel, and the we publish messages based on the users relationship to each other. But, there has to be that relationship in order for it to make any sense.
One example that one of our teams was doing was that when you look at a Document, you subscribe to a WebSocket channel for that document. Then, as relevant changes are made, events are pushed into that document specific channel.
But, to your point, there still needs to be a mechanism to say which users should get which messages. And, for us, the Pusher SaaS product handles that.
I'm kind of babbling here because I don't have a good answer for you 😆 It's a challenging problem.
Thank you for this. Seeing the java.util.concurrent.LinkedBlockingQueue code helped a lot with a threading module I was writing.
@Steve,
Very cool! And, the
concurrent
package in Java had lots of great stuff for cross-thread interactions. I don't need it that often; but, when I do, it always has what I need.Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →