This time, we'll take the API we built and set up a frontend React application with Helix in ClojureScript to be deployed with the API. We'll restructure the backend code and folder structure to keep the code base cleaner. So, bear with me first with the minor refactoring and setting up the scene on the API side. After this step, we can add server-side routing to serve the API and the static files. SPAs do routing in the front end, so we need to default to the index.html
to enable refreshing the site and successfully loading the page. This also allows direct links to frontend paths to be handled correctly. We won't be touching on frontend routing this time around.
You can find the previous steps from the earlier posts in the Building on DigitalOcean App Platform series.
Refactoring the API
First, let's update the source file structure from this
❯ tree src
src
└── main.clj
to this.
❯ tree src
src
└── clj
└── api
├── db.clj
└── main.clj
We'll add a new path src/cljs
for the frontend-related code in a moment.
Let's continue where we left off last time after we created the migrations and moved the database-related code from the original namespace main
to api.db
.
(ns api.db
(:require [migratus.core :as migratus]
[next.jdbc :as jdbc]
[next.jdbc.date-time]))
;; Use hardcoded default for now.
;; Configuration management needs to dealt with later.
(def dev-jdbc-url
"jdbc:postgresql://localhost:5432/db?user=user&password=password")
(defn get-db-conf []
{:dbtype "postgres"
:jdbcUrl (or (System/getenv "JDBC_DATABASE_URL")
dev-jdbc-url)})
(defn migrations-config []
{:db (get-db-conf)
:store :database
:migration-dir "migrations/"
:migration-table-name "migrations"
:init-in-transaction? false})
(defn create-migration [{:keys [name]}]
(migratus.core/create (migrations-config)
name))
(defn datasource []
(jdbc/get-datasource (get-db-conf)))
(defn get-migrations []
(jdbc/execute! (datasource) ["SELECT * FROM migrations"]))
(defn run-migrations []
(migratus/migrate (db/migrations-config)))
And then use the api.db
in api.main
and migrate the rest of the code.
(ns api.main
(:require [ring.adapter.jetty :as jetty]
[reitit.ring :as ring]
[clojure.java.io :as io]
[clojure.string :as str]
[api.db :as db])
(:gen-class))
(defn get-port []
(Integer/parseInt (or (System/getenv "PORT")
"8000")))
(defn app [_request]
(let [migrations (db/get-migrations)]
{:status 200
:headers {"Content-Type" "application/edn"}
:body (str migrations)}))
(defn -main [& _args]
(db/run-migrations)
(jetty/run-jetty #'app {:port (get-port)}))
One last thing: update the paths in deps.edn
and the :exec-fn
for run
and create-migration
.
modified api/deps.edn
@@ -1,4 +1,4 @@
-{:paths ["src" "resources"]
+{:paths ["src/clj" "resources" "compiled-resources"]
:deps {org.clojure/clojure {:mvn/version "1.11.0"}
ring/ring-core {:mvn/version "1.6.3"}
@@ -10,7 +10,7 @@
:aliases {:run
{:main-opts ["-m" "main"]
- :exec-fn main/-main}
+ :exec-fn api.main/-main}
@@ -24,4 +25,4 @@
:create-migration
{:exec-args {:name nil}
- :exec-fn db/create-migration}}}
+ :exec-fn api.db/create-migration}}}
Now, we are good to continue on the CLJS side of things.
Configure Shadow CLJS
We'll be using Shadow CLJS to compile the ClojureScript.
shadow-cljs
provides everything you need to compile your ClojureScript projects with a focus on simplicity and ease of use. The provided build targets abstract away most of the manual configuration so that you only have to configure the essentials for your build. Each target provides optimal defaults for each environment and get an optimized experience during development and in release builds.
Shadow CLJS has a built-in development time HTTP server to serve the files, but we won't use it this time since we want to serve the files from our backend to keep the development setup as close as possible to the deployment configuration. But before getting there, we must first configure the build process, which has three steps:
Configure Shadow CLJS
Define NPM dependencies
Write ClojureScript code
1. Configure Shadow CLJS
Shadow CLJS is configured with shadow-cljs.edn
file. It contains all of the ClojureScript-related configuration, dependencies, source file paths, build configurations, etc. I have added the cider/cider-nrepl and binaryage/devtools for convenience, but they are not required for the build.
The configuration is minimal, with only the browser build target defined. This means that when we run shadow-cljs compile/release app
we'll get a Javascript bundle meant to be used in a browser.
{:source-paths ["src/cljs"]
:dependencies [[binaryage/devtools "0.9.7"]
[cider/cider-nrepl "0.36.0"]
;; React wrapper
[lilactown/helix "0.1.5"]]
:builds {:app {:target :browser
:output-dir "compiled-resources/public/js"
:asset-path "/js"
:modules {:main {:entries [app.main]
:init-fn app.main/init}}
:devtools {:preloads [devtools.preload]
:after-load app.main/init}}}}
The application output-dir
is where the results will be compiled.
2. Define NPM Dependencies
NPM dependencies are defined in the package.json
file. It has the npm dependencies the project uses, and since we are configuring a Helix app, we'll need the react
and react-dom
as dependencies.
{
"name": "web",
"version": "0.0.1",
"private": true,
"devDependencies": {
"shadow-cljs": "2.26.2"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
3. ClojureScript Source
For this step, we only need some ClojureScript code to get started. Let's start with something simple and return to the application code later.
(ns app.main)
(. js/console log "Hello, app.main!")
Confirm the Build Works
Now that we have all the required steps completed let's compile the release build to see if we set up everything correctly.
❯ npx shadow-cljs release app
shadow-cljs - config: /home/tvaisanen/projects/digitalocean-clojure-minimal/api/shadow-cljs.edn
shadow-cljs - connected to server
[:app] Compiling ...
[:app] Build completed. (45 files, 1 compiled, 0 warnings, 1.25s)
❯ ls -ul compiled-resources/public/js
.rw-r--r-- 175 tvaisanen 2 Jan 11:58 main.js
.rw-r--r-- 1.3k tvaisanen 2 Jan 11:58 manifest.edn
var shadow$provide = {};
(function(){
'use strict';/*
Copyright The Closure Library Authors.
SPDX-License-Identifier: Apache-2.0
*/
console.log("Hello, app.core!");
}).call(this);
It looks like it's working as expected. We are ready to continue to the next phase of serving the files from our backend.
Serving the SPA
In the previous step, we did not yet configure a way for the browser to load the built files. For this, we need an HTML file that loads the Javascript. So, let's create the file resources/public/index.html
to do just that.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" />
<script src="/js/main.js" type="text/javascript"></script>
</body>
</html>
As you might have noticed, the index file has a relative path to the compiled resources, but our files are in resources
and compiled-resources
. We want to keep the static files separate from the build artifacts. This helps keep the build process cleaner since we do not need to think whether the compile-resources folder has development files. It'll be created in the build time and kept from version control.
❯ tree resources
resources
├── migrations
│ ├── 20231208151434-test.down.sql
│ └── 20231208151434-test.up.sql
└── public
└── index.html
❯ tree compiled-resources
compiled-resources
└── public
└── js
├── main.js
└── manifest.edn
Effectively, we want to merge the resources
and compiled-resources
in the backend, when the files are served, their respective paths are seen by the browser like they are coming from the same path. We can do this by adding both resources
and compiled-resources
to the paths in deps.edn
.
modified api/deps.edn
@@ -1,4 +1,4 @@
-{:paths ["src/clj" "resources"]
+{:paths ["src/clj" "resources" "compiled-resources"]
Then, we look at resources by their names in the backend.
(require '[clojure.java.io :as io])
(for [resource (concat (file-seq (io/file (io/resource "public")))
(file-seq (io/file (io/resource "public/js"))))]
(.getName resource))
;; => ("public" "index.html" "js" "main.js" "manifest.edn")
As expected, we can find all of the files with their names. This enables us to create a resource handler for the API to serve both resource directories. Next, let's jump to the backend routing.
Backend Routing
We'll be using metosin/reitit for the backend routing. It also provides functions to serve the resources.
modified api/deps.edn
@@ -3,9 +3,10 @@
- migratus/migratus {:mvn/version "1.5.4"}}
+ migratus/migratus {:mvn/version "1.5.4"}
+ metosin/reitit {:mvn/version "0.7.0-alpha7"}}
We need a couple of updates on the API code.
Refactor the previous
app
to/api/*
handler
Configure the router to serve both the
api
and the static files.
(ns api.main
(:require [api.db :as db]
[ring.adapter.jetty :as jetty]
[reitit.ring :as ring]
[clojure.java.io :as io]
[clojure.string :as str])
(:gen-class))
(defn get-port []
(Integer/parseInt (or (System/getenv "PORT")
"8000")))
(defn handler [_request]
(let [migrations (db/get-migrations)]
{:status 200
:headers {"Content-Type" "application/edn"}
:body (str migrations)}))
(defn index-handler
[_]
{:status 200
:headers {"content-type" "text/html"}
:body (io/file (io/resource "public/index.html"))})
(def app
(ring/ring-handler
(ring/router
[["/api"
["*" {:get handler}]]
["/" index-handler]
["/*" (ring/create-resource-handler
{:not-found-handler
(fn [{:keys [uri] :as r}]
(if (str/starts-with? uri "/api")
{:status 404}
(index-handler r)))})]]
{:conflicts (constantly nil)})
(ring/create-default-handler)))
(defn -main [& _args]
(db/run-migrations)
(jetty/run-jetty #'app {:port (get-port)}))
Let's break the router definition into parts to see what's happening. Our requirements, at the time being, for the routing are:
to be able to serve both
api
and static assets.return index.html if the static asset is not found
serve the App from the root
(ring/router
[;; serve API
["/api"
["*" handler]]
;; serve app from root
["/" index-handler]
;; in other case serve static
["/*" (ring/create-resource-handler
{:not-found-handler
(fn [{:keys [uri] :as r}]
(if (str/starts-with? uri "/api")
;; if the uri is an API path
;; serve NOT FOUND
{:status 404}
;; every other case serve the app
;; so that FE routing can try to
;; resolve the path
(index-handler r)))})]]
{:conflicts (constantly nil)})
Let's add a dev/clj/user.clj
namespace to start the server via REPL
(ns user
(:require [api.main :as main]))
(def server (atom nil))
(defn start! []
(reset! server
(main/start! {:port 8000
:join? false})))
(defn stop! []
(.stop @server))
(comment
(start!)
(stop!))
And create an alias to load the user
namespace by default.
modified api/deps.edn
- :aliases {:run
+ :aliases {:dev
+ {:extra-paths ["env/clj"]
+ :ns-default user}
❯ clj -M:dev
2024-01-02 13:41:03.950:INFO::main: Logging initialized @670ms
Clojure 1.11.0
user=> (start!)
Jan 02, 2024 1:41:07 PM clojure.tools.logging$eval2887$fn__2890 invoke
INFO: Starting migrations
Jan 02, 2024 1:41:07 PM clojure.tools.logging$eval2887$fn__2890 invoke
INFO: Ending migrations
2024-01-02 13:41:07.296:INFO:oejs.Server:main: jetty-9.2.21.v20170120
2024-01-02 13:41:07.305:INFO:oejs.ServerConnector:main: Started ServerConnector@2c6f022d{HTTP/1.1}{0.0.0.0:8000}
2024-01-02 13:41:07.306:INFO:oejs.Server:main: Started @4026ms
#object[org.eclipse.jetty.server.Server 0x2e7e4480 "org.eclipse.jetty.server.Server@2e7e4480"]
Now, we should be able to fetch the static resources
❯ http :8000/
HTTP/1.1 200 OK
Content-Length: 307
Content-Type: text/html
Date: Tue, 02 Jan 2024 11:44:41 GMT
Server: Jetty(9.2.21.v20170120)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="app" />
<script src="/js/main.js" type="text/javascript"></script>
<script>
app.core.init();
</script>
</body>
</html>
and access the API endpoints via HTTP.
❯ http :8000/api/
HTTP/1.1 200 OK
Content-Type: application/edn
Date: Tue, 02 Jan 2024 11:46:00 GMT
Server: Jetty(9.2.21.v20170120)
Transfer-Encoding: chunked
[{:migrations/id 20231208151434,
:migrations/applied #inst "2024-01-01T09:17:26.936000000-00:00",
:migrations/description "test"}]
And that's a wrap on the backend side.
Frontend Code
Finally, we can set up the React side of the application. Let's start by adding a utils
namespace and define some utilities for rendering the application.
(ns app.utils
(:require [helix.core :refer [$]]
["react" :as react]
["react-dom/client" :as rdom]))
(defn app-container []
(js/document.getElementById "app"))
(defonce root (atom nil))
(defn react-root []
(when-not @root
(reset! root (rdom/createRoot (app-container))))
@root)
(defn render
[App]
(.render (react-root)
($ react/StrictMode
($ App))))
Next, use the utils to render an App that renders the API response in the DOM.
(ns app.main
(:require [app.utils :as utils]
[cljs.pprint :refer [pprint]]
[clojure.edn :as edn]
[helix.core :refer [defnc]]
[helix.dom :as d]
[helix.hooks :as hooks]))
(defnc App []
(let [[data set-data] (hooks/use-state {})]
(hooks/use-effect
:once
(-> (js/fetch "/api")
(.then (fn [res] (. res text)))
(.then (fn [response-string]
(set-data (edn/read-string response-string))))))
(d/div
(d/div "App Here")
(d/pre (with-out-str
(pprint data))))))
(defn init []
(utils/render App))
Development Setup
For development time, we can start the CLJS compiler in watch mode to get the updated code in the browser whenever the source code changes.
npx shadow-cljs watch app
After this step, start the API with the :dev
profile.
❯ clj -M:dev
2024-01-02 13:41:03.950:INFO::main: Logging initialized @670ms
Clojure 1.11.0
user=> (start!)
This should be enough to get us started on writing code for the frontend. Shadow CLJS also provides a CLJS REPL in the browser for us to connect to, but that's out of the scope of this post. Read how to do this from the docs, or let me know if that'd be something you're interested in learning.
Deployment
We'll package the CLJS build into our Dockerfile so that the deployment process will be the same as previously. The only change is that we use the new Dockerfile for the build.
Let's create a new api/Dockerfile
to build the frontend code as a separate step,
FROM clojure:openjdk-17-tools-deps-alpine AS frontend-build
RUN apk add --update nodejs npm
WORKDIR /app
COPY . .
RUN npm install
RUN npx shadow-cljs release app
# Use a base without node dependencies for serving
FROM clojure:openjdk-17-tools-deps-alpine
WORKDIR /app
COPY . .
RUN ls /app
COPY --from=frontend-build /app/compiled-resources /app/compiled-resources
RUN clojure -P
CMD clojure -X:run
update the api/docker-compose.yml
,
modified api/docker-compose.yml
@@ -5,7 +5,7 @@ services:
api:
build:
context: .
- dockerfile: docker/dev.Dockerfile
+ dockerfile: Dockerfile
tags:
- "registry.digitalocean.com/clojure-sample-app/dev"
environment:
and then build, start the service, and confirm the build works as expected.
docker-compose up --build
Navigate to localhost:8000
to ensure the app is rendering. You can also see the migrations vector rendered on the screen.
If you haven't followed along with the previous posts, you can find the deployment instructions here.
Conclusion
There are multiple ways to deploy frontend applications. You could bake the static files into a container with a web server like NGINX, serve the files from a bucket with CDN, use the DigitalOcean static App, use a service like Netlify, or host the static files from your GitHub pages. But if you have an API component in your App. It makes sense to pair the frontend code tightly with the API to make the deployment phase more robust. What do I mean by that in this context? If we have the exact version of the frontend shipped with the API itself, there's no room for version mismatch.
Once again, thanks for reading; I hope you found this helpful. Here's the accompanying GitHub repository.