Created
February 18, 2022 12:56
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" ] ?: "" ); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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