Using htmx with CORS and (incidentally) Elixir

Context

I’ve been playing with htmx lately. I’m not much for JavaScript for most things but I’ve used intercooler.js in the past and htmx is the successor to it. It’s really nice for someone who wants nothing to do with React or Vue or similar.

However, in the past, I’ve always made the htmx (or intercooler.js) requests within the same application process. However, in my current experiment, I needed to make the request to another process bound to a different port on the same machine. This different process is sufficient to trigger the CORS protections in browsers.

The Code

My index.html looks something like the following:

<!DOCTYPE html>
<html>
	<head>
	</head>
	<body>
		<script src="htmx.min.js" defer></script>
		<button hx-get="http://localhost:8085/get/the/message" hx-swap="innerHTML">
		Click Me
		</button>
	</body>
</html>

It’s a basic set of ajax in htmx - when you click the button, it issues a GET request to the URL specified by the hx-get field in the <button ...> element. In this case, the server listening on :8085 just returns a string like “the time is now 03:18:25.212347” which is swapped into the button’s innerHTML field.

The server itself is a simple Elixir application using the Plug package. Briefly - Plug is an Elixir package for composing web applications with functions. It’s used in frameworks like Phoenix but you can think of it as a middleware component.

The Elixir application just exposes a route at /get/the/message, servicing the GET verb.

The Problem

So I started up the Elixir application and then serve my index.html using simple-http-server. After navigating to the web page, I get the following error in the Developer Console:

Access to XMLHttpRequest at ‘http://localhost:8085/get/the/message’ from origin ‘http://127.0.0.1:8000’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

Now - while I have plenty of experience with HTTP servers, it’s almost purely from the non-Browser perspective. I’ve spent most of my career writing services that make requests to other services or command line tools that make requests to services.

This is my first encounter with CORS though I’m loosely familiar with the idea. After some digging, I discover my server needs to respond with the appropriate response header - Access-Control-Allow-Origin. Cool! Let’s add the header… and… womp womp. It’s still not working.

Looking at the Developer Console, I see an OPTIONS request that was blocked with the explanation of:

Response body is not available to scripts (Reason: CORS Missing Allow Origin)

Huh - so I’ve never actually seen the OPTIONS verb used in practice. Doing some digging, it looks like it’s mostly used in browsers which tracks since I don’t do much browser work. After some groping in the dark, I discover that the OPTIONS request is a side effect of htmx issuing Preflight requests to check if the CORS protocol is understood by the server.

Alright - so based on this, sounds like I need to add support for the OPTIONS verb in our router and serve the expected response headers for the OPTIONS request as well. Easy enough:

  options "/get/the/message" do
    conn
    |> put_resp_header("Access-Control-Allow-Origin", "http://127.0.0.1:8000")
    |> put_resp_header("Access-Control-Allow-Headers", "Content-Type, hx-current-url, hx-request")
    |> put_resp_header("Access-Control-Allow-Methods", "GET")
    |> put_resp_header("Access-Control-Max-Age", "86400")
    |> send_resp(204, "{}")
  end

This chunk of code adds four headers. It allows the CORS request in the first place from the process bounding to :8000, it allows headers to be requested for Content-Type, hx-current-url, hx-request, and it permits the GET verb. Oh - and it sets the Max Age of the access control response to a day.

And drumroll…

Let’s test it out and… success!

There’s nothing specific to this about Elixir as the server process. It could be Go or Rust, you’d just need to attach the appropriate response headers and support the right verbs.

Idle thought: I’m not sure if this is something nginx or similar would’ve handled for me in some instances. Maybe?

Posted on 2022-05-17