How To: Websockets with Ably

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:

  1. Install Maven
  2. Create a new project folder to run your build
  3. 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>

  1. From your terminal, go to your folder and run mvn clean package
  2. A folder will be created named /target/ that will include the fat jar.
  3. 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.
  4. 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.

  1. 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;
}

}
  1. 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:

  1. Create an html page that includes https://cdn.ably.io/lib/ably.min-1.js
  2. 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>
  1. At this point, your javascript is setup to connect to a channel named test and is subscribed to typing and message 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.
  2. You can send a message from your CFML app to this channel by calling application.ably.sendChannelMessage("test", "message", {"key":"value"});
  3. 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!

2 Likes

Oh thats great! :heart_eyes:
Thanks for this post!

Since Ably meters your usage, and your cost is directly associated with your usage, it’s important to be efficient with how many connections and sent messages you make. Ably will throttle you on how many concurrent websocket connections are in your plan, so try to create as few connections as you can.

Keep in mind that when you create an Ably Realtime instance like this:

const ably = new Ably.Realtime({
	authUrl: 'https://your-ably-auth-endpoint.cfm',
	clientId: 'client-identifier'
});

… you are creating a single websocket connection that can handle any number of channels you throw at it. So when you create your authentication token, you can provide access to those channels rather than creating multiple websocket connections for each channel.

For example, when creating a websocket connection, your authentication endpoint might authenticate into the following channels:

capabilities = {
  "thread:abc123" = ["publish","subscribe"],
  "thread:xyz456" = ["subscribe"],
  "notifications"  = ["subscribe"],
  "room:*"         = ["publish","subscribe"]
};

Notice the wildcard in the “room:*” channels. This means that the user has publish and subscribe access to any channel that starts with room:

You will use the Ably Java SDK along with this struct to create your authentication token. See the example in the first post above, and modify it to your needs with this in mind.

1 Like