EventSource And Generating Server-Sent Events In ColdFusion / Lucee CFML

Cyberdime
Published: November 26, 2022

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.

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="https://www.bennadel.com/blog/./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:

  1. The HTTP header, Content-Type, on the ColdFusion response has to be text/event-stream.

  2. 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.

  3. Individual messages must be delimited by two successive new-line characters.

  4. We can optionally include a line prefixed with event:, for custom named events. By default, the event emitted in the client-side JavaScript is message. However, if we include an event: line in our server-sent payload, the value we provide will then become the event-type emitted on the EventSource instance.

  5. 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="https://www.bennadel.com/blog/./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:

An HTML frameset in which one frame is posting messages onto a queue and another frame is immediately receiving messages from that queue via EventSource and server-sent events.

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.

Source: www.bennadel.com