If you are interested in adding robust websocket support to your Lucee apps, check out the Ably platform at https://ably.com/. It’s very easy to get things going and hopefully this post will make it even easier for you. This is a long post and I’ll correct any errors that are found. I look forward to your comments!
First, the code and methodology that I’m sharing is likely to be improved/enhanced. This is what I know after working with Ably for a couple weeks. I’m far from an expert, but feel free to DM me if you need any help.
FYI: I used ChatGPT to help me understand the platform and how to get things moving. I found that it knew a lot about the API and how things worked, so I barely had to look at the documentation.
1. Create Your Ably Account
First thing is to setup an Ably account. Their free tier doesn’t require a credit card and it’s all you need to get started.
Next, you’ll need to create an app. I gave it a name “Dev/Websockets” and selected Java as my language, and chose “Just Exploring” as my application type.
You’ll be building a Pub/Sub application in Ably.
2. Compile/build the Ably Java SDK with All Dependencies
This was tricky, but you’ll need to build the Ably Java SDK. You can probably get a pre-built one from Maven, but I chose to build my own as a fat jar that includes all dependencies.
If you’re a Java expert, maybe you can provide more clarification on this, but I felt that the best way to maintain my code going forward is to only deal with a single jar file that has everything. This took me a bit of time to figure out since I’m not a Java expert.
Here’s what I did:
- Install Maven
- Create a new project folder to run your build
- Create a pom.xml file at the root of this folder with the following settings:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>ably-fat</artifactId>
<version>1.2.51</version>
<packaging>jar</packaging>
<dependencies>
<!-- Ably Java SDK dependency -->
<dependency>
<groupId>io.ably</groupId>
<artifactId>ably-java</artifactId>
<version>1.2.51</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<!-- Disable minimization to ensure all classes are included -->
<minimizeJar>false</minimizeJar>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
- From your terminal, go to your folder and run
mvn clean package
- A folder will be created named
/target/
that will include the fat jar. - Copy the fat jar file to your lucee install folder:
{lucee-6}/tomcat/lucee-server/context/lib/
NOTE: There seems to be a bug in Lucee 6.2.0.321 that prevents jars from loading in this location, so I am running 6.2.1.112-RC and Lucee is able to load my Ably java sdk. - Restart Lucee and you’re ready to go
3. Add Ably to Your App
To add some Ably functionality in your CFML code, create a file named ably.cfc
that you’ll add to your application scope. This is all my code, and it works for me. It’s not perfect or optimized or well tested… I only included code needed for this example.
- Create a file named ably.cfc
component {
/*
Ably websockets:
Requires the Ably Java SDK to be installed in Lucee.
*/
Variables.ablyApiKey = "your-api-key";
Variables.ablyRealtime = "";
Variables.ably = "";
public struct function generateAblyTokenRequest (
string clientID="dev-client-1",
struct capability={"*":["subscribe", "publish"]}) {
var errorCode = 500;
var r = {
"errorCode": 0,
"error": "",
"authToken": {}
};
try {
var tokenParams = createObject("java", "io.ably.lib.rest.Auth$TokenParams");
if (Arguments.clientID != "") {
tokenParams.clientId = Arguments.clientID;
}
tokenParams.capability = serializeJSON(Arguments.capability);
tokenParams.ttl = 3600000;
var tokenRequest = Variables.ablyRest.auth.createTokenRequest(tokenParams, NullValue());
r.authToken = {
"keyName": tokenRequest.keyName,
"ttl": tokenRequest.ttl,
"capability": tokenRequest.capability,
"clientId": tokenRequest.clientId,
"timestamp": tokenRequest.timestamp,
"nonce": tokenRequest.nonce,
"mac": tokenRequest.mac
};
} catch (any e) {
// maybe avoid reporting the error on production bc this is called
// over and over from a browser and reporting errors could cause a
// massive amount of errors,
// instead perhaps rely on human testing and observation that websockets are not
// working? Not sure what's best for your app.
r.errorCode = errorCode;
r.error = e.message & " " & e.detail;
}
return r;
} // generateAblyTokenRequest()
public void function sendChannelMessage (
required string channel,
required string type,
required any data) {
try {
// Get the channel reference
var ablyChannel = getAblyChannel(Arguments.channel);
if (IsNull(ablyChannel)) {
throw(message="Failed to get channel: [#Arguments.channel#]");
return;
}
// Publish a message to the channel
ablyChannel.publish(Arguments.type, IsSimpleValue(Arguments.data) ? Arguments.data : serializeJSON(Arguments.data));
} catch (any e) {
// handle your error here
}
} // sendChannelMessage()
private any function getAblyChannel (
required string channel) {
// I had to jump through some java hoops to get this to work due to
// some class not found errors, which was fixed with the weird way of
// getting the channel object. Be careful if you change it. In my testing,
// this code is very performant, taking less than 1 ms to get the channel.
// However I have not tested it in production yet after thousands of
// channels have been used/created.
var channels = Variables.ably.getClass().getField("channels").get(Variables.ably);
var strClass = createObject("java", "java.lang.String").getClass();
var getMethod = channels.getClass().getMethod("get", [strClass]);
var jString = createObject("java", "java.lang.String").init(Arguments.channel);
getMethod.setAccessible(true);
var ablyChannel = getMethod.invoke(channels, [jString]);
return ablyChannel;
} // getAblyChannel()
public ably function init() {
Variables.ablyRealtime = createObject("java", "io.ably.lib.realtime.AblyRealtime");
var clientOptions = createObject("java", "io.ably.lib.types.ClientOptions");
var options = clientOptions.init();
options.key = Variables.ablyApiKey;
Variables.ably = Variables.ablyRealtime.init(options);
if (IsNull(Variables.ably)) {
throw(message="Failed to initialize Ably", errorCode=500);
}
Variables.ablyRest = createObject("java", "io.ably.lib.rest.AblyRest").init(Variables.ablyApiKey);
if (IsNull(Variables.ablyRest)) {
throw(message="Failed to initialize ablyRest", errorCode=500);
}
return this;
}
}
- Add it to your application scope in
onApplicationStart()
so you can reference it:application.ably = createObject("ably").init();
3. Make an Authentication Endpoint
Make a .cfm
file that is used by Ably to authenticate users into your websocket channels. This example is super simple… yours will be different. Basically, Ably will poll a GET request to this endpoint to maintain the connection to your channels.
The way I’m doing it is that the Ably clientId
is set to be the userID
of the current user. I’m also explicitly sending the channel name into this endpoint so I know which channel I’m authenticating and I only have one endpoint. You’ll see this in the JavaScript below.
<cfscript>
setting showDebugOutput=false;
header name="Content-Type" value="application/json";
try {
param name="url.channel" type="string" required=true;
param name="url.clientID" type="string" default=""; // my userID
// make sure url params are sanitized!
hasAccess = true; // you make your own rules!
// Return an HTTP 403 Forbidden (or 401 Unauthorized)
// if user doesn't have access to the specified channel
if (!hasAccess) {
cfheader(statusCode=403, statusText="Forbidden");
echo("The current user does not have access to this channel.");
abort;
}
// refer to the Ably API for more information about capability for the channel
capability = {"#url.channel#":["subscribe","publish"]};
token = application.ably.generateAblyTokenRequest(
clientID=url.clientID,
capability=capability
);
if (token.errorCode != 0) {
errorCode = token.errorCode;
throw(message="Failed to generate token", errorCode=errorCode, detail=token.error);
}
echo(serializeJSON(token.authToken));
} catch (any e) {
param name="errorCode" type="numeric" default=500;
cfheader(name="Status", statusCode=errorCode, value="#errorCode# #e.message#", statusText="#errorCode# #e.message#");
echo(SerializeJSON({
"errorCode": errorCode,
"error": e.message
}));
}
</cfscript>
3. Make a Test Channel
Let’s make a test channel in a simple html page.
If you don’t know about channels in Ably, they’re incredibly flexible and you don’t need to define them in advance. As soon as you reference one, it’s created for you. So let’s create a channel named test
, but real world channels can be just about anything and have parts to them that are separated with colons, like new-york:travel:trains
or messages:23304
. Then you can parse the channel and get what you need for authentication.
Let’s create our test page:
- Create an html page that includes
https://cdn.ably.io/lib/ably.min-1.js
- Inside your page, some javascript that will connect to an Ably websocket channel named
test
. This is just the JS code… use your imagination to do stuff with this channel. I built a simple chat page, but you do you.
<script>
const ably = new Ably.Realtime({
authUrl: 'path/to/your/auth/cfm/file',
clientId: 'your-user-id',
authParams: {
channel: 'test'
}
});
const channel = ably.channels.get('test');
channel.subscribe('typing', function(msg) {
console.log('typing!!');
});
channel.subscribe('message', function(msg) {
console.log('message!!');
});
</script>
- At this point, your javascript is setup to connect to a channel named
test
and is subscribed totyping
andmessage
messages. It’s up to you how you want to finish this test, and it wouldn’t take you very long to build a chat page using this javascript. - You can send a message from your CFML app to this channel by calling
application.ably.sendChannelMessage("test", "message", {"key":"value"});
- If needed, you can setup a CFML listener for your channel. However, this listener will not automatically have access to your application world. I believe there is a way around that, and there are multiple ways to get at least some access to your application. Here’s some psuedo-code to setup your listener:
var ablyChannel = application.ably.getAblyChannel(channelName);
// create a CFC for your listener to the "test" channel
var channelCFC = CreateObject("listeners.test").init(ablyChannel);
var listener = CreateDynamicProxy(
cfc=channelCFC,
interfaces = ["io.ably.lib.realtime.Channel$MessageListener"]
);
if (IsNull(listener)) {
throw(message="Failed to create listener with CreateDynamicProxy(): [#arguments.channel#]", errorCode=500);
}
ablyChannel.subscribe(listener);
Here’s a simple test.cfc file that listens to the test
channel (but does nothing):
component {
// Called when a message is sent
public void function onMessage (
required any message) {
// message is a Java object from Ably
// io.ably.lib.types.Message
try {
/*
NOTE: to access the application, we could call Lucee's InternalRequest()
function
https://docs.lucee.org/reference/functions/internalrequest.html
*/
// Do stuff here
} catch (any e) {
}
} // onMessage()
public void function onConnectionStateChanged (
required any stateChange) {
// Do stuff here
} // onConnectionStateChanged()
}
Conclusion
While Lucee is still working on robust websocket support, I realized that waiting isn’t an option for me. So I looked around and I decided to give Ably a try. I’ve since moved all my old websocket support in Lucee 5 over to Ably and I cound’t be happier. It’s super robust and I think the pricing will work. It gives me everything I need and more and I don’t have to deal with any of the infrastructure requirements that Lucee websockets will need.
Hope this helps and I look forward to your comments!