Writing a Cloudflare worker with squint and bun

In this post I'll describe how to write a Cloudflare worker with squint and bun.

Introduction

As I tried to build an Advent of Code playground for squint, I wanted to support downloading puzzle input from adventofcode.com. Doing this directly from the playground resulted in CORS issues. Mario Trost in the #adventofcode channel on Clojurians Slack suggested that this could be solved using a Cloudflare worker and indeed it could. Thanks Mario!

A Cloudflare worker is tiny "serverless" application that scales automatically. The first 100k requests a day are free. A worker can be written in JavaScript, among other languages. Workers written in JavaScript can use the Fetch API which comprises of fetch, Request, Response and more standardized stuff, available as global objects in browsers, Node.js, deno, bun and other browser-inspired JavaScript runtimes. These APIs are basically all you need to build a worker.

Squint is a ClojureScript dialect designed for easier JS interop and smaller bundle output. As such, it seems like a good fit for Cloudflare workers. The smaller the code, the better the (cold) startup time.

Bun is a fast all-in-one JavaScript runtime / bundler / toolkit. Bun seems a good tool to use for developing Cloudflare workers since with little boilerplate you can get something running quickly. Bun supports hot reloading of worker code too.

Hello world

To produce a "hello world" worker only the following is necessary:

squint.edn:

{:paths ["src"]
 :extension "js"
 :output-dir "out"}

The Squint CLJS files are going into src and compiled .js files will be written to out.

The only source file:

src/index.cljs:

(ns index)

(defn ^:async handler [{:keys [method] :as req} _env _ctx]
  (js/Response. "hello world"))

(def default
  {:fetch handler})

Beware that your handler isn't going to be called fetch since this will conflict with js/fetch, something that tripped me up. So I just called it handler. Calling a var default in squint will make it the default export, which is something Cloudflare workers use.

Now run bun install squint-cljs and then bun squint watch. In parallel, run bun --hot out/index.js. This will spin up a server. You can make change to the code and new requests will see the changes. This is very convenient for local development and testing.

To bundle everything to a single file, run:

bun build --minify --outdir=dist/out/index.js

Then you can run bun dist/index.js to prove to yourself that the standalone JS works.

The standalone JavaScript file is somewhere around 2 kilobytes. Seriously: 2kb, that's it :)!

To deploy to Cloudflare, I'm using wrangler. It needs a small config file, called wrangler.toml which describes the name of the application and the location of the main JS file:

name = "hello-world"
main = "dist/index.js"
compatibility_date = "2023-09-04"

After writing the config file, you can run bun wrangler deploy. You will be asked to log in to the Cloudflare dashboard, etc. Eventually your application will be running at https://hellow-world.<your-user>.workers.dev.

Proxy

The final worker code can be seen below. The handler looks at an incoming request, and decides whether it's a GET or OPTION request. In the handling of the GET request, the URL params day, year and aoc-token are pulled out. While developing I noticed that the URLSearchParams object implements a Map-like ad-hoc interface, but squint's get function wasn't aware of this, so initially I couldn't use destructuring like this:

(let [{:keys [foo]} (-> (js/URL. "https://foo.com?foo=1") :searchParams)] foo)

to get foo out of this object. Similarly for the Headers object from Response. Both have a get method. I decided to make the squint get function work with any JS datastructure that has a get method. I'm not entirely sure if that is a good idea though since arbitrary methods called get may perform side effects... Let me know in the comments! But after doing so, destructuring worked on the search params.

Then a request is made to https://adventofcode.com to retrieve the input. When doing this from a non-browser, you don't get into any CORS issues. Note that we can nicely use js-await for waiting for promise results. The text that adventofcode.com returned is passed back along with "Access-Control-Allow-Origin" "* headers to satisfy all the CORS ... stuff.

I also noticed the browser playground was doing an OPTION request so I also need to handle those, returning the most permissive headers to satisfy the CORS gods.

src/index.cljs

(ns index)

(defn ^:async handler [{:keys [method] :as req} _env _ctx]
  (if (= :GET method)
    (let [params (-> req :url (js/URL.) :searchParams)
          {:keys [aoc-token day year]} params]
      (if (and aoc-token day year)
        (let [resp (js-await (js/fetch (str "https://adventofcode.com/" year "/day/" day "/input")
                                       {:headers {:cookie (str "session=" aoc-token)}}))
              body (js-await (.text resp))
              resp (js/Response. body {:headers {"Access-Control-Allow-Origin" "*"}})]
          resp)
        (js/Response. "Set 'aoc-token, 'day' and 'year' as a URL query parameter" {:status 400
                                                                                   :headers {"Access-Control-Allow-Origin" "*"}})))
    ;; response for :OPTION
    (js/Response. nil {:status 200
                       :headers {"Access-Control-Allow-Origin" "*"
                                 "Access-Control-Allow-Methods" "GET,HEAD,POST,OPTIONS"
                                 "Access-Control-Allow-Headers" "*"}})))

(def default
  {:fetch handler})

The final production JS is still around 2-3kb. After another `bun wrangler deploy` this browser playground for Advent of Code started working:

Advent of Code playground

You need to insert your Advent of Code session token in the input box, run Compile and after that it's stored in local storage, for other puzzles you might want to solve this year! Give it a spin and let me know what you think.

The source code for the worker is available here.

Published: 2023-11-19

Tagged: clojure squint

Archive