Setting Up My ColdFusion + Hotwire Demos Playground
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.cfm
- not 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="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="sub/index.htm">Try going to a sub folder</a> →
</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="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.
Reader Comments
Hi Ben - do you have a suggestion for a "newb's guide to Docker and ColdFusion?"
Thanks! 😁
@Matt,
Yes! Charlie Arehart has a GitHub repository called Awesome CF Compose , which demonstrates how to create a variety of ColdFusion containers with either Adobe ColdFusion or Lucee CFML.
Then, there's the CommandBox Docker Hub page, which provides a more hand-held way to spin up a ColdFusion container. Basically, you use their base image, and then just define the ColdFusion engine that you want to use via the ENV variables. In fact, I'm using the CommandBox approach for my playground. Here's my
Dockerfile
:... you can see I'm using the
ortussolutions/commandbox
base image (the rest of that file is just installed node.js). Then, in mydocker-compose.yaml
, I'm simply defining theBOX_SERVER_APP_CFENGINE
and mounting my code volume:Now, to be clear, I am not very good at Docker 😆 so, if you want to know how to do something special and custom, it'll be over my head. But, as far as getting a development environment working, this approach has been relatively easy.
@Ben,
Thank You! 😄 Big Appreciate 😁
@Matt,
My pleasure. To be clear, I personally find Docker to be complicated and confusing 😨 I've been using it for a few years now, and I barely have any idea what I'm doing. I was never a person who dealt much with servers directly, so Docker kind of wraps a black-box around another black-box for me. As such, I'm not sure how easy or hard other people find this kind of stuff.
That said, using Docker has allowed me to do a lot more experimentation that I would have been able to do using other means. 💪
Thanks for exploring this topic.
I love using CFMl for my projects. The spirt of Hotwire is to enable you to use CFML MORE in place where you would have to move application logic into a JS frame work.
Until this series, it seemed like it would be way too hard to use hotwire with CFML.
Ben - would it be possible to make Hotwire CFML native and remove its dependence on node?
@Peter,
I'm with you - when I can use more CFML in more places, I feel like I'm winning! 💪 🙌 🎉
In this case, the dependence on Node.js is simply for the building of the JavaScript and Less CSS files. You could, from what I understand, just include the Turbo JS file in your
<head>
element. I feel like the Hotwired site used to have an example of this; but, now when I go to the Turbo Installation Docs, it has no example and directs you to Skypack for more information.But, I'm pretty sure you can take advantage of all the Turbo Drive / Turbo Frames / Turbo Streams functionality with a simple JavaScript file.
That said, if you need to compile any CSS or start to bundle JavaScript Controllers in Hotwire Stimulus, I'm pretty sure you'll need to have some sort of build script (which is often in Node.js, though other things like ESBuild and Vite are becoming hawt these days - though I have no experience with them).
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →