Skip to main content
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Jake Scott
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Jake Scott

Forking Hotwire Turbo To Make It ColdFusion Compatible

By
Published in , Comments (13)

For a while now, my blog has been running on the Hotwire stack of client-side technologies which includes Turbo. Turbo seeks to push a lot of the rich interactivity back to the server-side, making robust applications easier to develop. Only, it doesn't work with the .cfm file extension used by ColdFusion. And, for reasons that remain unclear, the Turbo maintainers seem highly resistant to providing a configuration option for file extensions. As such, I wanted to fork Turbo in order to make it play nicely with ColdFusion.

View my Turbo CFML repository on GitHub →

The primary mechanism provided by Turbo is a complete take-over of the browser's native navigation features. Instead of having every link-click and form-submission lead to a full page reload, Turbo hijacks the events, prevents the default reload, and fulfills the request using AJAX. This reduces the number of resources that have to be processed by the browser; and, opens the door for preloading, lazy-loading, persisted elements, and advanced caching.

But, Turbo doesn't override navigation events indiscriminately. Each event has to meet certain conditions, like belonging to the same origin and pointing to a file extension that indicates a likely HTML response. Internally, Turbo checks these conditions, in part, by calling an isHTML() method that looks at an allow-listed pattern of file extensions:

export function isHTML(url) {
  return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/)
}

This capture group, (?:htm|html|xhtml|php), should really be exposed as a developer-facing configuration. But, the Turbo maintainers insist that this would not be wise. As such, my goal here is fork the Turbo repository and change this one line of code to include, cfc|cfm|cfml:

export function isHTML(url) {
  return !!getExtension(url).match(/^(?:|\.(?:cfc|cfm|cfml|htm|html|xhtml|php))$/)
}

Forking the Turbo GitHub repository and changing that line of code was easy. What I didn't know how to do was then make that forked code available in my downstream projects.

As luck would have it, on a recent episode of the Working Code podcast, Adam Tuttle mentioned that package.json could point directly to a GitHub repository (instead of an npm hosted package). In theory, this would allow me to npm install my version of Turbo directly from my forked repository.

I had no idea where to start. But, this guide from warp.dev pointed me in the right direction. Instead of defining a package.json dependency using a version number, I can define it using a GitHub URL:

{
  "name": "test",
  "devDependencies": {
    "parcel": "2.12.0",
    "turbo-cfml": "github:bennadel/turbo-cfml#cfml-8.0.4-2"
  }
}

Here, I'm defining my turbo-cfml vendor library as my forked GitHub URL. And, by including #cfml-8.0.4-2 at the end of that URL, I'm telling npm to install a specific branch or, in my case, tag (cfml-8.0.4-2).

After making this update and calling npm install, I could, indeed, see turbo-cfml in my node_modules folder. However, upon closer inspection, the turbo-cfml folder contained nothing but the README.md and package.json files.

At first, I tried to create an npm script that would build the distribution files for turbo-cfml by stepping down into the node_modules folder and running subsequent npm commands:

{
  "scripts": {
    "build-turbo-cfml": "cd node_modules/turbo-cfml/ && npm i && npm run build"
  }
}

And, after doing this, I was able to import turbo-cfml into my project's JavaScript files. But, this felt janky and left me with a lot of node_modules bloat that I didn't need.

After doing some more Googling, I learned more about how package.json configurations work in conjunction with npm install. There a several fields that determine which files are installed and which files are exposed during an import / require call.

The files property:

The optional files field is an array of file patterns that describes the entries to be included when your package is installed as a dependency.... Some special files and directories are also included or excluded regardless of whether they exist in the files array.

The main property:

The main field is a module ID that is the primary entry point to your program. That is, if your package is named foo, and a user installs it, and then does require("foo"), then your main module's exports object will be returned.... If main is not set, it defaults to index.js in the package's root folder.

The root Turbo package.json does include a files property that points to its dist folder:

{
  "files": [
    "dist/*.js",
    "dist/*.js.map"
  ]
}

But, the dist/ folder was being excluded in the .gitignore file. Meaning, there was no dist/ folder in the GitHub repository. Which meant, when I used npm install to load my turbo-cfml repo, there were no files to include.

To "fix" this, I went into my forked repository and removed dist/ from the .gitignore. I then ran yarn build (in my turbo-cfml repository) to generate the distribution files and committed them to the turbo-cfml repository. Normally, committing compiled files to a repository is not recommended. However, by doing this, I make the repository much easier to consume in other JavaScript projects.

At this point, I went back to my test project, deleted the node_modules folder, and ran npm install again. And, I saw my turbo-cfml folder show up with the necessary dist/ files!

To test that this enabled me to use turbo-cfml, I create a simple JavaScript file that imported the turbo-cfml module and started listening for a turbo:visit event on the document:

// Import the Turbo library from MY FORKED VERSION!
import * as Turbo from "turbo-cfml";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

document.documentElement.addEventListener(
	"turbo:visit",
	function handleClick( event ) {

		// Strip off the origin for more concise logging.
		var url = event.detail.url
			.replace( "http://127.0.0.1:62625", "" )
		;

		console.group( "Turbo Visit" );
		console.log( `URL: ${ url }` );
		console.log( event );
		console.groupEnd();

	}
);

Now, the whole point of this was to enable Hotwire Turbo for the .cfm file extension. So, I built a small little ColdFusion site with three files:

  • index.cfm
  • about.cfm
  • contact.cfm

These files don't do anything other than load the main.js file and cross-link to each other. And, when I load this ColdFusion site and click around, I can see several important things:

  • Turbo has been loaded.
  • Turbo is preloading links on hover.
  • Turbo is intercepting the link-clicks.
  • Turbo is preventing full-page reloads (which we can see because the log isn't cleared between navigation events).
Turbo CFML library applying Hotwire Turbo to CFM file extensions.

Woot woot! My turbo-cfml library has successfully enabled Hotwire Turbo for .cfm file extensions. Hopefully this is just a temporary approach that can be retired once Turbo exposes the file extension pattern as a configuration option. But, until then, this will serve me nicely in ColdFusion.

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

Reader Comments

15,902 Comments

@Christopher,

The HTMX stuff is very interesting as well. This week, I actually just watched HTMX: The Practical Guide on Udemy by Maximilian Schwarzmüller. I think there's a lot of overlap between the HTMX and the Hotwire approaches. But, I think one difference is that Hotwire seems to err harder on the progressive enhancement side. ie, getting the site to work without JavaScript first, then enhance it with Hotwire. Most of the HTMX stuff that I see seems to assume that all the scripts loaded. Though, that could just be the "happy path" that people teach (and may not be a fair representation).

Now, I'm not saying that Hotwire is better - only that it seems to stress the progressive enhancement (PE) stuff a bit harder. That said, the PE part makes it a lot harder to think about (in my opinion); and, I'm not sure how much I love it.

Plus, neither libraries have any answer for the question: "how do I organize my CSS?"

59 Comments

That's funny. I keep hearing people say that Turbo handles progressive enhancement better than htmx, but I like the way htmx handles it. I strongly suspect that means that I just don't appreciate something basic about Turbo.

In htmx, I can use a <form action="page.cfm"> or a < a href="page.cfm"> and then just add an hx-boost="true" attribute to either to give it htmx functionality.

I often prefer, however, to have one main page that includes the site design (in case a full page load is needed) and a partial page that can be loaded directly by htmx.

In which case I would have something like < a href="fullpage.cfm" hx-get="partial.cfm">. Obviously, I take advantage of my server-side scripting to include the partial in the full page to keep the code DRY.

I'd also argue that, at least in the case of htmx, I like that it just does the one small thing.

What am I missing about the Turbo approach to progressive enhancement that makes it better?

15,902 Comments

@Steve,

This could simply be a perception problem. I've only read some blogs and took a Udemy course on HTMX, I haven't actually built anything with it. So, any feelings I have on the matter are all academic at this point. Perhaps so much of what I've constructed as a mental modal stems from the fact that most HTMX write-ups that I come across feature a button with a get:

<button hx-get="...">Do It</button>

It feels like most HTMX tutorials work in reverse. Meaning, they start off by showing all this stuff that is not progressive enhancement; and then finish by mentioning that hx-boost exists. It almost feels like an afterthought.

I think maybe I read that hx-boost is a more recent addition to the HTMX ecosystem. So, treating it as an afterthought would make sense in that regard.

On the other hand, Hotwire is (the way I understand it) a decade-long evolution of the "Turbo Links" library, which was basically 37signals answer to hx-boost. So, most of the Hotwire write-ups start off by saying use the native stuff; and then, add non-native stuff if / only if you actually need it.

But, again, this could all be a perception issue. In fact, the anchor link example that you give where you have both an href and an hx-get, I literally didn't know that was possible because I've never seen an example where that is used—everyone seems to be obsessed with putting hx-get on button elements.

I suspect the overlapping Ven diagram between the two libraries is actually pretty close. They both send special HTTP headers in the request; they both can target different elements; they both allow for "out of band" swaps. So, at the end of the day, they probably do do the same stuff. As such, I think it's boils down to an education issue (on my part at least).

59 Comments

@Ben Nadel,

Yeah, I love using hx- attributes with regular ones for progressive enhancement. I use hx-boost pretty rarely, really, and use that approach the rest of the time.

I really like the flexibility of that over the "boost" approach that Turbo seems to take. Like you for htmx, however, I haven't actually used Turbo, so I'm sure I don't have a solid grasp of it.

If I'm being honest about my preference for htmx over Turbo, though, it is probably mostly just because that is the one I found first. :-)

6 Comments

There is a ridiculous simplicity with Rails / Hotwire that I love. To update table results.

after_create_commit {broadcast_prepend_to "requests"}
after_update_commit {broadcast_replace_to "requests"}
after_destroy_commit {broadcast_remove_to "requests"}

<tbody id="requests">
   <%= turbo_stream_from "requests" %>
   <%= render @requests%>
</tbody>

I also love coldfusion/Lucee though and would prefer a windows based web platform. So using HTMX with coldfusion works out great as well. I think Rails/Turbo/Hotwire is just packaged better.

15,902 Comments

@Steve,

If I'm being honest about my preference for htmx over Turbo, though, it is probably mostly just because that is the one I found first.

... ha ha, but ain't that the truth with most things 😆 Years ago, I was at a JavaScript conference in NYC and Douglas Crawford (creator of the JSON specification) was giving a presentation about a new language (a kind of re-imagining of JavaScript) that he was working on. And he made the comment that it doesn't matter how much better it is, that you basically have to wait for the current generation of programmers to die before anything better can become popular, because we're all so set in our ways.

I know I am very guilty of this.

15,902 Comments

@Christopher,

I don't have any direct experience with Rails; but, it seems that so much of the productivity comes from the holistic nature of the ecosystem. All the stuff that Rails can do with Turbo, we can do in CFML. But, Rails basically builds all these helper methods into the server-side framework that make is integrate so seamlessly.

One thing that I always find very curious, though, when I hear people talk about using Rails, is how simple it appears to be to have WebSockets "just work". And, to send all these updates over WebSockets. I can kind of understand that if you only have 1 server (and the 1 client is only ever connected to the 1 server). But, it would seem that the moment you have more than 1 server, WebSockets get an order of magnitude harder to manage. And, I don't know if that's just a misunderstanding on my part; or, if the vast majority of Ruby projects only ever exist on 1 server. Or, if they have some unique tech that makes it work well.

15,902 Comments

So, I just tried to use this approach inside a Docker container, and I was running into errors. I'm not sure if all of the errors were related to the same root problem. But, ultimately, I had to install git in the Docker container in order to be able to install the dependency from GitHub.

It's possible that this is only true because of the github: prefix in the package.json - I'm not sure if changing that to https: would have changed it.

I also had to update my version of nodejs in order to remove some other errors (possibly unrelated to all of this).

15,902 Comments

Also, it looks like the #cfml-8.0.4-2 at the end of my GitHub path wasn't working inside my Docker container. I'm not sure if this is a misunderstanding of how it works (ie, branches vs tags compatibility); or, if there's something fundamentally different about the install inside the Docker image.

13 Comments

Is it possible to get a "compiled" JS version of hotwire/turbo from this project so we can include in a project without the build step?

15,902 Comments

@Peter,

Good question. Let me see what I can do. I've never compiled a JavaScript library for external consumption before - I just want to make sure I'm not making any foolish mistakes.

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel