Setting Up My ColdFusion + Hotwire Demos Playground

Cyberdime
Published: January 29, 2023

A month ago, I started building a ColdFusion and Hotwire application as a learning experience. Only, once I finished the basic ColdFusion CRUD (Create, Read, Update, Delete) features, I didn’t really know how to go about applying the Hotwire functionality. I realized that I bit off more than I could chew; and, I needed to go back and start learning some of the Hotwire basics before I could build an app using the “Hotwire way”. As such, I’ve started a new ColdFusion and Hotwire Demos project, where I intended to explore stand-alone aspects of the Hotwire framework.

View this code in my ColdFusion Hotwire Demos project on GitHub.

Nothing in this repository is intended to be “production ready”! This repository is not a shining example of Docker containerization, ColdFusion code organization, Parcel.js bundling, or component modularization. My intent here is only to get enough stuff working such that I can learn more about how Hotwire and ColdFusion / Lucee CFML might interact.

The context that I am creating here is a CommandBox-driven Docker container that also installs Node.js 19.x. Here’s my Dockerfile for the build:

FROM ortussolutions/commandbox
RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - \
	&& apt-get install -y \
		build-essential \
		nodejs \

In this case, nodejs installs the node version that is provided by nodesource.com call. And, the build-essential package provides libraries like make and cc that are needed by the Parcel.js bundler (and its dependencies).

The base image for CommandBox doesn’t install any particular ColdFusion engine by default – I have to tell it which engine I want using environment variables in my docker-compose.yaml file. In this case, I’m using Lucee CFML 5.3.10.

version: "2.4"
services:
  lucee:
    build:
      context: "./docker/"
      dockerfile: "Dockerfile"
    ports:
      - "80:8080"
      - "8080:8080"
    volumes:
      - "./demos:/app"
    environment:
      BOX_SERVER_APP_CFENGINE: "lucee@5.3.10+97"
      BOX_SERVER_PROFILE: "development"
      cfconfig_adminPassword: "password"
      LUCEE_CASCADE_TO_RESULTSET: "false"
      LUCEE_LISTENER_TYPE: "modern"
      LUCEE_PRESERVE_CASE: "true"

This gives me a basic ColdFusion server; but, as I mentioned in a previous article, Hotwire Turbo Drive doesn’t work with .cfm file extensions. This is because ColdFusion can serve up anything (it’s hella powerful!); and, Turbo Drive needs assurances that HTML is going to be served. As such, Turbo Drive will only intercept navigation actions that involve .htm / .html file extensions.

To get my ColdFusion server to play nicely with Turbo Drive, I’m going to be routing all my ColdFusion links through a non-existing template, hotwire.cfm. Then, I’m going to be using the cgi.path_info property to define the actual URL.

So, for example, if I want to navigate to index.cfm, I’m going to define my link as:

./hotwire.cfm/index.htm

I need to use .htm as the file extension in the URL so that Turbo Drive intercepts the navigation event. Of course, what I really want to do is execute index.cfmnot index.htm. For this, I am using the ColdFusion application framework’s onRequest() event handler in order to override the script execution:

component
	output = false
	hint = "I define the application settings and event handlers."
	{
	// Define the application settings.
	this.name = "HelloWorld";
	this.applicationTimeout = createTimeSpan( 0, 1, 0, 0 );
	this.sessionManagement = false;
	this.setClientCookies = false;
	// ---
	// LIFE-CYCLE METHODS.
	// ---
	/**
	* I process the requested script.
	*/
	public void function onRequest( required string scriptName ) {
		// The root-absolute path to this demo app (used in the page module).
		request.appPath = "/hello-world/app";
		// Basecamp's Hotwire Turbo Drive will only work with static ".htm" or ".html"
		// file extensions (at the time of this writing). As such, in order to get Turbo
		// Drive to play nicely with ColdFusion's ".cfm" file extensions, we're going to
		// route all requests through the index file and then dynamically execute the
		// corresponding ColdFusion template.
		// --
		// CAUTION: In a production application, blindly invoking a CFML file based on a
		// user-provided value (path-info) can be dangerous. I'm only doing this as part
		// of a simplified demo.
		if ( cgi.path_info.len() ) {
			var turboScriptName = cgi.path_info
				// Replace the ".htm" file-extension with ".cfm".
				.reReplaceNoCase( "\.html?$", ".cfm" )
				// Strip off the leading slash.
				.right( -1 )
			;
			include "./#turboScriptName#"; 
		} else {
			include scriptName;
		}
	}
}

Notice here that if the cgi.path_info value is populated, I replace the .htm file extension with a .cfm file extension and then CFInclude the calculated template path. What this means is that my routing to:

./hotwire.cfm/index.htm

… gets translated into this line of ColdFusion code in my onRequest() event handler:

include "./index.cfm";

Again, I want to reiterate that this is not intended to be production-ready code. In fact, dynamically executing CFML templates based on user-provided paths is definitely unsafe. This is just enough for me to get Lucee CFML and Hotwire playing nicely together for exploratory purposes.

In order to try and hide this HTML-CFML bait-and-switch from the code in my ColdFusion templates, I’m using the <base> tag to define the hotwire.cfm proxy in my layout (truncated version of page.cfm):

<cfif ( thistag.executionMode == "end" )>
	<cfsavecontent variable="thistag.generatedContent">
		
		<!doctype html>
		<html lang="en">
		<head>
			<cfoutput>
				<base href="#request.appPath#/hotwire.cfm/" />
			</cfoutput>
			<!---
				CAUTION: Since I'm setting the base-href to route through a ColdFusion
				file, our static assets have to use root-absolute paths so that they
				bypass the base tag settings.
			--->
			<cfoutput>
				<script src="#request.appPath#/dist/main.js" defer async></script>
				<link rel="stylesheet" type="text/css" href="#request.appPath#/dist/main.css"></link>
			</cfoutput>
		</head>
		<body>
			<cfoutput>
				#thistag.generatedContent#
			</cfoutput>
		</body>
		</html>
	</cfsavecontent>
</cfif>

Thanks to my <base> tag:

<base href="#request.appPath#/hotwire.cfm/" />

… when I have a content link like this:

<a href="https://www.bennadel.com/blog/some-page.htm">Goto Some page</a>

… it will be evaluated by the browser as:

/hello-world/app/hotwire.cfm/some-page.htm

… which my Application.cfc onRequest() event handler will then execute as:

include "./some-page.cfm";

It’s frustrating that I have to jump through these hoops in order to get ColdFusion and Turbo Drive to work together. But, the alternative would be to build out much more robust routing logic on the ColdFusion side; and, that’s completely tangential to the goal of this repository. As such, I’m opting into some silly code in order to minimize the amount of boiler plate logic that I have to put in place.

With this page.cfm template above, I can then build relatively simple ColdFusion pages by wrapping content in a CFModule tag that executes page.cfm as a custom tag. For example, here’s my hello world root index page:

<cfmodule template="./tags/page.cfm">
	<h1>
		Hello World
	</h1>
	<p>
		This is the root page in my CFML+Hotwire exploration.
	</p>
	<p>
		<a href="https://www.bennadel.com/blog/sub/index.htm">Try going to a sub folder</a> &rarr;
	</p>
</cfmodule>

And, here’s my sub-folder index page:

<cfmodule template="../tags/page.cfm">
	<h1>
		Sub Folder
	</h1>
	<p>
		Folders are a fun, if you can get into it.
	</p>
	<p>
		<a href="https://www.bennadel.com/blog/index.htm">Back to the root page</a> ^
	</p>
</cfmodule>

Note that my link from the sub-folder back to the root-folder is index.htm and not ../index.htm. This is because all relative paths are appended to the <base [href]>, not to the current folder.

Once I had my basic ColdFusion application in place, I then went about installing Hotwire Turbo, Hotwire Stimulus, and Parcel:

{
	"name": "hello-world",
	"scripts": {
		"js-build": "parcel build          ./src/js/main.js --dist-dir ./app/dist/",
		"js-watch": "parcel watch --no-hmr ./src/js/main.js --dist-dir ./app/dist/",
		"less-build": "parcel build        ./src/less/main.less --dist-dir ./app/dist/",
		"less-watch": "parcel watch        ./src/less/main.less --dist-dir ./app/dist/"
	},
	"author": "Ben Nadel",
	"license": "ISC",
	"dependencies": {
		"@hotwired/stimulus": "3.2.1",
		"@hotwired/turbo": "7.2.4",
		"@parcel/transformer-less": "2.8.3",
		"parcel": "2.8.3"
	}
}

The npm scripts are intended to be executed from within the running Docker container (which is the whole point of containerization). So, in order to compile my JavaScript, I first “bash into” the running container:

docker-compose run lucee bash

Then, change to the desired directory:

cd hello-world/

And then, run my npm scripts inside the container:

npm run js-watch

ASIDE: In this case, I disabled Hot Module Reloading (HMR) because I could not figure out how to get the WebSocket connection to work with the Docker container. It seemed no matter which ports I exposed, the network request (ws://localhost/) would fail. Build systems are not my strong-suit; and, manually refreshing the page is not very painful for me.

In order to make sure that Hotwire’s Tubro Drive was wired-up, my main.js file imports the Turbo Drive library and binds to the load event:

// Import core modules.
import * as Turbo from "@hotwired/turbo";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
var turboEvents = [
	// "turbo:click",
	// "turbo:before-visit",
	// "turbo:visit",
	// "turbo:submit-start",
	// "turbo:before-fetch-request",
	// "turbo:before-fetch-response",
	// "turbo:submit-end",
	// "turbo:before-cache",
	// "turbo:before-render",
	// "turbo:before-stream-render",
	// "turbo:render",
	"turbo:load",
	// "turbo:before-frame-render",
	// "turbo:frame-render",
	// "turbo:frame-load",
	// "turbo:frame-missing",
	// "turbo:fetch-request-error"
];
for ( var eventType of turboEvents ) {
	document.documentElement.addEventListener(
		eventType,
		( event ) => {
			console.group( "Event:", event.type );
			console.log( event.detail );
			console.groupEnd();
		}
	);
}

Now, if I run my ColdFusion application, and navigate between the two pages, I get the following:

Notice that the ColdFusion application appears to be doing full-page refreshes of the content. However, we can tell by the Console that the page is not reloading (otherwise the console history would be cleared after each navigation). Instead, Hotwire Turbo is intercepting the click events, preventing the default browser behavior, and updating the content via fetch() requests.

In fact, if we jump over to the Network tab of the Chrome dev tools, we can see that fetch is how the requests are being made:

At this point, I now have a relatively simple (albeit non-production-ready) way to start a ColdFusion container that can compile Hotwire code. Now, I should be able to start exploring some of the many features of the Hotwire framework.

Want to use code from this post?
Check out the license.

Source: www.bennadel.com