Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created February 18, 2022 12:56
Show Gist options
  • Save bennadel/285adde987a7ba196bff88e934a1e508 to your computer and use it in GitHub Desktop.
Save bennadel/285adde987a7ba196bff88e934a1e508 to your computer and use it in GitHub Desktop.
Ignoring Loopback WebSocket Events From Pusher In Lucee CFML 5.3.8.206
<cfscript>
config = deserializeJson( fileRead( "../config.json" ) );
// For the sake of simplicity, I'm just re-creating the Pusher ColdFusion component on
// every request. In a production context, I would cache this in the Application scope
// or a dependency-injection (DI) container.
pusher = new lib.Pusher(
appID = config.pusher.appID,
appKey = config.pusher.appKey,
appSecret = config.pusher.appSecret,
apiCluster = config.pusher.apiCluster
);
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// This event will be pushed to ALL CLIENTS that are subscribed to the demo-channel,
// including the browser that made THIS API call in the first place. In order to help
// prevent the origin browser from responding to this event, let's echo the browser
// UUID in the event payload.
// --
// NOTE: The Pusher API provides a mechanism for ignoring a given SocketID when
// publishing events. However, the part of our client-side code that makes the AJAX
// calls doesn't know anything about Pusher (or the state of the connection). As such,
// there's no SocketID to be injected into the AJAX call.
pusher.trigger(
channels = "demo-channel",
eventType = "click",
message = {
browserUuid: ( request.browserUuid ?: "" )
}
);
</cfscript>
component
output = false
hint = "I define the ColdFusion application settings and event-handlers."
{
// Define application settings.
this.name = "PusherClientIdRoundTrip";
this.applicationTimeout = createTimeSpan( 0, 1, 0, 0 );
this.sessionManagement = false;
this.directory = getDirectoryFromPath( getCurrentTemplatePath() );
this.root = "#this.directory#../";
// Define per-application mappings.
this.mappings = {
"/lib": "#this.root#lib"
};
// ---
// PUBLIC METHODS.
// ---
/**
* I get called once at the start of each incoming ColdFusion request.
*/
public void function onRequestStart() {
var httpHeaders = getHttpRequestData( false ).headers;
// When the browser is making AJAX calls to the API, it's going to inject a UUID
// for each client into the incoming HTTP Headers. Let's pluck that out and store
// it in the REQUEST scope where it can be globally-available across the
// processing of the current request.
request.browserUuid = ( httpHeaders[ "X-Browser-UUID" ] ?: "" );
}
}
<cfscript>
config = deserializeJson( fileRead( "../config.json" ) );
// In order to setup the Pusher client in the browser, we need to pass-down some of
// configuration data. DO NOT SEND DOWN APP SECRET!
clientConfig = serializeJson({
appKey: config.pusher.appKey,
apiCluster: config.pusher.apiCluster,
});
</cfscript>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
Ignoring Loopback WebSocket Events From Pusher In Lucee CFML 5.3.8.206
</title>
</head>
<body style="user-select: none ;">
<h1>
Ignoring Loopback WebSocket Events From Pusher In Lucee CFML 5.3.8.206
</h1>
<!---
This counter value will be incremented both locally via click-handlers and
remotely via Pusher WebSockets. The goal is not to keep the counter in sync across
clients, only to emit events across clients (keeping the demo super simple).
--->
<div class="counter" style="font-size: 40px ;">
0
</div>
<script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script type="text/javascript" src="https://js.pusher.com/7.0/pusher.min.js"></script>
<script type="text/javascript">
var config = JSON.parse( "<cfoutput>#encodeForJavaScript( clientConfig )#</cfoutput>" );
// Let's assign a universally-unique ID to every browser that the app renders.
// This UUID will be "injected" into each outgoing API AJAX request as an HTTP
// header. This is not specifically tied to the Pusher functionality; but, will be
// used to prevent "loopback" events.
var browserUuid = "browser-<cfoutput>#createUuid().lcase()#</cfoutput>";
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
var pusher = new Pusher(
config.appKey,
{
cluster: config.apiCluster
}
);
var channel = pusher.subscribe( "demo-channel" );
// Listen for all "click" WebSocket events on our demo channel.
channel.bind(
"click",
function handleEvent( data ) {
console.group( "Pusher Event" );
console.log( data );
// When the ColdFusion server sends a "click" event to the Pusher API,
// Pusher turns around and sends that event to every client that is
// subscribed on the channel, including THIS BROWSER. However, since we're
// OPTIMISTICALLY INCREMENTING THE COUNTER LOCALLY, we don't want to ALSO
// increment it based on the WebSocket event. As such, we want to ignore
// any events that were triggered by THIS browser.
if ( data.browserUuid === browserUuid ) {
console.info( "%cIgnoring loopback event from local click.", "background-color: red ; color: white ;" );
console.groupEnd();
return;
}
console.info( "%cAccepting event from other browser.", "background-color: green ; color: white ;");
console.groupEnd();
// ASIDE: Couldn't we just use a "User ID" to ignore loopback events? No.
// Even if we included the originating "user" in the event, that's still
// not sufficient. A single user may have MULTIPLE BROWSER TABS open. And,
// we don't want to ignore the event in all browser tabs for that user -
// we only want to ignore the event in the single browser tab that
// optimistically reacted to an event.
// --
// If this event came from a DIFFERENT browser, then let's update our
// count locally to reflect the event.
incrementCounter();
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// Each count will be locally-stored in the browser. The point of this demo isn't
// to synchronize the counts across browsers, it's to prevent loopback event
// processing for a single browser.
var counter = $( ".counter" );
var count = Number( counter.html().trim() ); // Initialize counter from DOM state.
jQuery( document ).click( handleDocumentClick );
/**
* I handle clicks on the document, using them to trigger click API calls.
*/
function handleDocumentClick( event ) {
// OPTIMISTICALLY increment the counter locally.
incrementCounter();
// Make an API call to trigger the click event on all Pusher-subscribed
// clients. We're including the browser's UUID so that we can later ignore
// loopback events for this client.
// --
// NOTE: In this demo, there's only one action. But, try to imagine that this
// headers{} object was being augmented in a central location using an API
// client or something like an HTTP Interceptor - some place that knows
// nothing about Pusher or WebSockets.
$.ajax({
method: "post",
url: "./api.cfm",
headers: {
"X-Browser-UUID": browserUuid
}
});
}
/**
* I increment the current count and render it to the DOM.
*/
function incrementCounter() {
counter.html( ++count );
}
</script>
</body>
</html>
component
output = false
hint = "I provide methods for interacting with the Pusher App API."
{
/**
* I initialize the Pusher client with the given application settings.
*/
public void function init(
required string appID,
required string appKey,
required string appSecret,
required string apiCluster,
numeric defaultTimeout = 10
) {
variables.appID = arguments.appID;
variables.appKey = arguments.appKey;
variables.appSecret = arguments.appSecret;
variables.apiCluster = arguments.apiCluster;
variables.defaultTimeout = arguments.defaultTimeout;
// When sending an event to multiple channels at one time, the Pusher API only
// allows for up to 100 channels to be targeted within a single request. If an
// event needs to go to more channels, the groupings will be spread across
// multiple HTTP requests.
variables.maxChannelChunkSize = 100;
variables.newline = chr( 10 );
}
// ---
// PUBLIC METHODS.
// ---
/**
* I trigger the given event on the given channel (singular) or set of channels.
*
* CAUTION: Since the API request may need to be split across multiple HTTP requests,
* based on the number of channels being targeted, the overall API request workflow
* should NOT be considered as atomic. One "chunk" of channels may work and then a
* subsequent "chunk" may fail. As such, it is recommended that each API request be
* kept under the max-channel limit.
*/
public void function trigger(
required any channels,
required string eventType,
required any message,
numeric timeout = defaultTimeout
) {
// When sending an event to multiple channels, there is a cap on how many channels
// can be designated in each request. As such, we have to chunk the channels up
// into consumable groups.
var channelChunks = isSimpleValue( channels )
? buildChannelChunks( [ channels ] )
: buildChannelChunks( channels )
;
var serializedMessage = serializeJson( message );
for ( var channelChunk in channelChunks ) {
makeApiRequest(
method = "POST",
path = "/apps/#appID#/events",
body = serializeJson({
name: eventType,
data: serializedMessage,
channels: channelChunk
}),
timeout = timeout
);
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I split the given collection of channels up into chunks that can fit into a single
* API request.
*/
private array function buildChannelChunks( required array channels ) {
var chunks = [];
var chunk = [];
for ( var channel in channels ) {
chunk.append( channel );
// If the current chunk has reached capacity, move onto the next chunk.
if ( chunk.len() == maxChannelChunkSize ) {
chunks.append( chunk );
chunk = [];
}
}
// Since chunks are appended only when a new chunk is being created, let's gather
// the last chunk if it has any channels in it.
if ( chunk.len() ) {
chunks.append( chunk );
}
return( chunks );
}
/**
* I generate the API request signature for the given request values.
*/
private string function generateSignature(
required string method,
required string path,
required string authKey,
required string authTimestamp,
required string authVersion,
required string bodyMd5,
) {
var parts = [
method, newline,
path, newline,
"auth_key=#authKey#&",
"auth_timestamp=#authTimestamp#&",
"auth_version=#authVersion#&",
"body_md5=#bodyMd5#"
];
// CAUTION: Signature MUST BE LOWER-CASE or it will be rejected by Pusher.
var signature = hmac( parts.toList( "" ), appSecret, "HmacSHA256", "utf-8" )
.lcase()
;
return( signature );
}
/**
* I make the HTTP request to the Pusher API. The parsed payload is returned; or, an
* error is thrown if the HTTP request was not successful.
*/
private struct function makeApiRequest(
required string method,
required string path,
required string body,
required numeric timeout
) {
var domain = ( apiCluster.len() )
? "https://api-#apiCluster#.pusher.com"
: "https://api.pusher.com"
;
var endpoint = "#domain##path#";
var bodyMd5 = hash( body, "md5" ).lcase();
var authKey = appKey;
// The authentication timestamp must be in Epoch SECONDS. Pusher will reject any
// request that is outside of 600-seconds from the current time.
var authTimestamp = fix( getTickCount() / 1000 );
var authVersion = "1.0";
var authSignature = generateSignature(
method = method,
path = path,
authKey = authKey,
authTimestamp = authTimestamp,
authVersion = authVersion,
bodyMd5 = bodyMd5
);
http
result = "local.httpResponse"
method = method
url = endpoint
charset = "utf-8"
timeout = timeout
getAsBinary = "yes"
{
httpParam
type = "header"
name = "Content-Type"
value = "application/json"
;
httpParam
type = "url"
name = "auth_key"
value = authKey
;
httpParam
type = "url"
name = "auth_signature"
value = authSignature
;
httpParam
type = "url"
name = "auth_timestamp"
value = authTimestamp
;
httpParam
type = "url"
name = "auth_version"
value = authVersion
;
httpParam
type = "url"
name = "body_md5"
value = bodyMd5
;
httpParam
type = "body"
value = body
;
}
// Even though we are asking the request to always return a Binary value, the type
// is only guaranteed if the request comes back successfully. If something goes
// wrong (such as a "Connection Failure"), the fileContent will still be returned
// as a simple string. As such, we have to normalize the extracted payload.
var fileContent = isBinary( httpResponse.fileContent )
? charsetEncode( httpResponse.fileContent, "utf-8" )
: httpResponse.fileContent
;
if ( ! httpResponse.statusCode.reFind( "2\d\d" ) ) {
throw(
type = "Pusher.NonSuccessStatusCode",
message = "Pusher API returned with non-2xx status code.",
extendedInfo = serializeJson({
method: method,
endpoint: endpoint,
statusCode: httpResponse.statusCode,
fileContent: fileContent
})
);
}
try {
return( deserializeJson( fileContent ) );
} catch ( any error ) {
throw(
type = "Pusher.JsonParseError",
message = "Pusher API response could not be parsed as JSON.",
extendedInfo = serializeJson({
method: method,
endpoint: endpoint,
statusCode: httpResponse.statusCode,
fileContent: fileContent
})
);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment