The Stack Part 3: Building a Frontend

Christian Kjær in a casual setting :)
Christian Kjær
39 min read

·

16. October 2023

·

, , , , , , , , ,

In the last post we set up our deployment, fully automated on merge to our main branch. In this post we will be building our UI (Frontend) applications. See the full overview of posts here.

At the end of this post we will have:

  • A Next.js Frontend app with support for localization, using Tailwind CSS.
  • A Leptos Rust/WASM Frontend app with support for localization, using Tailwind CSS.
  • Automatic deployment of our Apps AWS using CDK, statically hosted using S3 + CloudFront.

We are essentially hedging our bets by building both a JavaScript-based Frontend, which is the safe bet, and a Rust/WASM-based Frontend, which is the future bet. We will be using the same GraphQL API for both, so we can easily switch between them.

There is quite a lot to cover. My recommendation is to clone down the Part 3 branch in the GitHub repository and use this post as an explanation of what is set up.

Prelude: Static Site Generation

While SSR (Server Side Rendering) is seeing a renaissance these days, we are intentionally avoiding this functionality. If you remember the architecture outlined in our introduction to the series, we want to be able to serve our Frontend entirely using static file hosting.

There are multiple reasons for this, going back to our core design goals:

  • Low cost: Static file hosting is extremely cheap, and scales as well as S3/CloudFront does (i.e. very well).
  • Low operational overhead: Static file hosting is extremely simple to operate—there are no servers for us to worry about and scale up/down as needed, no need for any orchestration yet.
  • Performant: Static file hosting is extremely performant, as the files are served directly from the edge, and not from a server.

We do sacrifice the ability to do SSR and the various User Experiences that can potentially bring, but the benefits are far outweighed by the downsides in this tradeoff.

Next.js

Next.js is one of the most popular React frameworks at the moment, and supports a lot of niceties as well as sets us on a path of a good structure from the get-go. It has a lot of momentum behind it, a strong focus on the Developer Experience, and uses React which makes it a very familiar option for a large majority of Frontend Engineers out there.

We'll once again use Bun, which you can install via:

$ curl -fsSL https://bun.sh/install | bash

While bun unfortunately doesn't fully support the Next.js App Router yet we will still rely on it for installing dependencies and being our general go-to tool for running anything JS related.

Setting up our Next.js App

Let's get our Next.js app set up, which we will call ui-app:

$ bun create next-app # Call it ui-app
 What is your project named? … ui-app
 Would you like to use TypeScript? … Yes
 Would you like to use ESLint? … Yes
 Would you like to use Tailwind CSS? … Yes
 Would you like to use `src/` directory? … Yes
 Would you like to use App Router? (recommended)  Yes
 Would you like to customize the default import alias (@/*)? … No

This gets us quite far, we now have an App we can run, Tailwind CSS is already set up, and we got a lot of the structure set up for us:

$ cd ui-app
$ bun run dev # or bun run build
next dev
   Next.js 13.5.4
  - Local:        http://localhost:3000

  Ready in 3s

Voila, we've got a little Hello World Next.js app!

Building Static Files

We need to do just one small change to our Next.js setup to make it output static files for us. We'll do this by adding output: "export" to our next.config.js file at the root of ui-app/:

1// @ts-check
2
3/** @type {import('next').NextConfig} */
4const nextConfig = {
5 output: "export",
6 trailingSlash: true,
7 experimental: {
8 // Statically type links to prevent typos and other errors when using next/link, improving type safety when navigating between pages.
9 typedRoutes: true,
10 },
11};
12
13module.exports = nextConfig;

We also enabled trailingSlash (docs here) to make Next.js work nicely with CloudFront.

This tells Next.js that we want to to Statically Export our files, which will generate an HTML file per route, which allows each route to serve the minimal content it needs, enabling faster page loads, instead of the traditional SPA approach of serving one large file upfront.

We get the best of both worlds here, as we still get the reduced bundle sizes typical of SSR, but can retain the static file advantage of SPAs.

Setting up Localization

As our customer-base grows, we will inevitably run into the need to localization. To do this, we will restructure our App with basic support for this, as well as bring in a dependency to help us with this, namely next-intl. Vercel also has some good documentation on how to Get Started here.

Let's start by adding the dependency:

$ bun add next-intl

We'll also create a folder that will contain our localization files, which we'll call messages/ in the root of the ui-app/ project:

$ mkdir messages

This allows us to set up some text for our first languages. Create an English locale, messages/en.json, with the following:

1{
2 "home": {
3 "intro": "Welcome!"
4 }
5}

And also a French locale, in messages/fr.json:

1{
2 "home": {
3 "intro": "Bienvenue!"
4 }
5}

To make this a bit nicer to work with, we'll also add typesafety by letting TypeScript know what keys we support in our localization function. Create a ui-app/global.d.ts file with the following:

1// Use type safe message keys with `next-intl`, based on the contents/keys of
2// our default english locale.
3type Messages = typeof import("./messages/en.json");
4declare interface IntlMessages extends Messages {}

This ensures that if we misspell a key, or even remove one later on, we will be highlighted of the incorrect usage by TypeScript.

We can now set up a route using the App Router to pick up our locale. We'll want the locale to be part of our URL as a prefix on all routes, so that we can pick it up as a dynamic segment and use it to load the correct localization file.

First we will create a folder where our localized pages will live in, and also clean up the default files that Next.js created for us:

$ mkdir "src/app/[locale]"
$ rm src/app/page.tsx src/app/layout.tsx

Let's create a simply page in here at src/app/[locale]/page.tsx, and get our welcome text from the localization file:

1"use client";
2
3import { useTranslations } from "next-intl";
4
5export default function Home() {
6 const t = useTranslations("home");
7
8 return (
9 // Name="grid place-content-center content-center h-screen">
10 <div class,linenos
11 // Name="text-6xl">{t("intro")}</h1>
12 <h1 class,linenos
13 // Name="grid gap-4 grid-cols-2">
14 <div class,linenos
15 <a href="/fr">Go to fr</a>
16 <a href="/en">Go to en</a>
17 </div>
18 </div>
19 );
20}

We'll need to mark the component as 'use client' for now, while next-intl is working on server-side support.

Since we removed existing layout file, we need to define a new one that also handles setting up our localization at the root of our components. We'll create a src/app/[locale]/layout.tsx file with the following:

1import "../globals.css";
2import type { Metadata } from "next";
3import { Inter } from "next/font/google";
4import { NextIntlClientProvider } from "next-intl";
5import { notFound } from "next/navigation";
6import { ReactNode } from 'react';
7import { promises as fs } from 'fs';
8
9export const metadata: Metadata = {
10 title: "Hello, World!",
11 description: "Ready to set things up",
12}
13
14const inter = Inter({ subsets: ["latin"] });
15
16/**
17 * Set up all supported locales as static parameters, which ensures we generate
18 * static pages for each possible value of `locale`.
19 */
20export async function generateStaticParams() {
21 // Construct an array of all supported locales based on the files in the `messages/` directory.
22 const localeFiles = await fs.readdir(`${process.cwd()}/messages`);
23 return localeFiles.map((f) => ({locale: f.replace(/\.json$/, "")}));
24}
25
26/**
27 * Load the contents of a given locale's messages file.
28 */
29async function messagesContent(locale: string) {
30 try {
31 return (await import(`../../../messages/${locale}.json`)).default;
32 } catch (error) {
33 console.error("Something went wrong", error);
34 notFound();
35 }
36}
37
38type Props = {
39 children: ReactNode;
40 params: {
41 locale: string;
42 };
43};
44
45export default async function Layout({ children, params: { locale } }: Props) {
46 const messages = await messagesContent(locale);
47 return (
48 <html lang={locale}>
49 // Name={inter.className}>
50 <body class,linenos
51 <NextIntlClientProvider locale={locale} messages={messages}>
52 {children}
53 </NextIntlClientProvider>
54 </body>
55 </html>
56 );
57}

To avoid needing to maintain a hardcoded list of locales, we dynamically find all the locales defined in our messages/ folder during build time, and construct a list of supported locales from this. We then pass the contents of this into NextIntlClientProvider.

We could imagine later on, once our translation file becomes massive, that we split up en.json into smaller segments such as en/home.json, and load these parts specifically in a app/[locale]/home/layout.tsx file. For now though, we'll keep it simple.

As the final piece of this puzzle, we need a way to let Next.js know where it should route to by default, since we removed the default root pages.

We unfortunately cannot use middlewares when statically exporting our site, so we will instead redirect the user upon loading the page. Create a src/app/page.tsx file with the following:

1import { redirect } from "next/navigation";
2
3export default function Root() {
4 redirect("/en");
5}

Along with a root layout file at src/app/layout.tsx:

import { ReactNode } from "react";

type Props = {
  children: ReactNode;
};

export default function Layout({ children }: Props) {
  return children;
}

You should now have a structure that looks like this:

.
├── README.md
├── bun.lockb
├── global.d.ts
├── messages
   └── en.json
   └── fr.json
├── next.config.js
├── package.json
├── postcss.config.js
├── public
   └── favicon.ico
├── src
   └── app
       ├── [locale]
       │   ├── layout.tsx
       │   └── page.tsx
       ├── favicon.ico
       ├── globals.css
       ├── layout.tsx
       └── page.tsx
├── tailwind.config.ts
└── tsconfig.json

And that's it! We're now able to run our app and check it out in the browser:

$ bun run dev
Screenshot of our ui-app

It may not look like much, but we've implemented a lot of the core functionality we need to get started, such as static builds and localization.

As the final step we will add our commands to just, extending our existing justfile:

1_setup-ui-app:
2 #!/usr/bin/env bash
3 set -euxo pipefail
4 cd ui-app
5 bun install

We'll also set up a new command for running our development server:

1# Run <project> development server, e.g. `just dev ui-app`.
2dev project:
3 just _dev-{{project}}
4
5_dev-ui-app:
6 #!/usr/bin/env bash
7 set -euxo pipefail
8 cd ui-app
9 bun dev

Leptos (Rust/WASM)

Leptos is one of the newer entries on the Rust/WASM scene. It has a radical focus on performance and scalability of your codebase, so that as your codebase grows your App doesn't just start to become slower which is a typical issue in React and VDOM-based frameworks.

Leptos should feel somewhat familiar, although it is more closely related to something like Solid.js which is based on Signals and not using a VDOM. Leptos has a good quick overview of features here and a nice FAQ here.

Setting up our Leptos App

We will be using Trunk for developing and building our Leptos App. Trunk is a great tool for developing Rust/WASM Apps, and is very similar to Bun (in a sense) in that it is a wrapper around the underlying tools. Let's install it first:

# Install dependencies
$ cargo install trunk

We can then set up our project, which we'll call ui-internal:

$ cargo init ui-internal
$ cd ui-internal

We'll immediately adjust our Cargo.toml file with the dependencies we'll need, as well as a few common WASM optimizations for our release builds:

1[package]
2name = "ui-internal"
3version = "0.1.0"
4edition = "2021"
5
6# Define our supported locales.
7[package.metadata.leptos-i18n]
8default = "en"
9locales = ["en"]
10locales-dir = "./messages"
11
12# Optimize for WASM binary size.
13[profile.release]
14opt-level = 'z'
15lto = true
16codegen-units = 1
17
18[dependencies]
19# Core leptos library.
20leptos = { version = "0.5.1", features = ["csr", "nightly"] }
21# Leptos Meta adds support for adjusting <head> from within components.
22leptos_meta = { version = "0.5.1", features = ["csr", "nightly"] }
23# Router and Route state management.
24leptos_router = { version = "0.5.1", features = ["csr", "nightly"] }
25# Leptos support for i18n localization.
26leptos_i18n = { version = "0.2", features = ["csr", "nightly"] }
27# Lightweight logging support.
28log = "0.4"
29
30# Common WASM libraries.
31wasm-bindgen = { version = "0.2" }
32console_log = { version = "1" }
33console_error_panic_hook = { version = "0.1" }

And finally, we'll use Rust Nightly to develop our App, which gives us a few better ergonomics:

$ rustup toolchain install nightly
$ rustup default nightly
$ rustup target add wasm32-unknown-unknown

Let's create a quick index.html file in the root of the ui-internal/ folder, just to get started:

1<!DOCTYPE html>
2<html>
3 <head></head>
4 <body></body>
5</html>

And replace the contents of our src/main.rs:

1use leptos::*;
2
3mod app;
4
5pub fn main() {
6 // Register log and panich handlers.
7 let _ = console_log::init_with_level(log::Level::Debug);
8 console_error_panic_hook::set_once();
9
10 mount_to_body(|| {
11 view! { <app::Layout /> }
12 });
13}

We'll also create a src/app.rs file with the following (we'll update this file later):

1use leptos::*;
2
3#[component]
4pub fn Layout() -> impl IntoView {
5 view! { <p>"Hello, world!"</p>}
6}

We can now run our App using Trunk:

$ trunk serve --open

Voila, we've got a little Hello World Leptos app!

Setting up Tailwind CSS

Let's configure Tailwind CSS for our Leptos App. First, we need to tell Tailwind where to look for files that might contain our CSS classes. Create a ui-internal/tailwind.config.ts file with the following:

1/** @type {import('tailwindcss').Config} */
2module.exports = {
3 content: {
4 files: ["*.html", "./src/**/*.rs"],
5 },
6 theme: {
7 extend: {},
8 },
9 plugins: [],
10};

We also need to tell trunk to build Tailwind CSS as part of its build process. We can do this by creating a ui-internal/Trunk.toml file:

1[[hooks]]
2stage = "pre_build"
3command = "sh"
4command_arguments = [
5 "-c",
6 "bunx tailwindcss --input resources/input.css --output public/output.css",
7]

This let's trunk know that before it builds our WASM App, it should run the bunx tailwindcss ... command, which will generate our Tailwind CSS file, which it puts into public/output.css.

Now, you might have noticed we also have an input file. Let's get that set up, along with a resources/ folder:

$ mkdir ui-internal/resources

We'll then create our base Tailwind CSS file at ui-internal/resources/input.css, mimicing our Next.js setup:

1@tailwind base;
2@tailwind components;
3@tailwind utilities;
4
5:root {
6 --foreground-rgb: 0, 0, 0;
7 --background-start-rgb: 214, 219, 220;
8 --background-end-rgb: 255, 255, 255;
9}
10
11@media (prefers-color-scheme: dark) {
12 :root {
13 --foreground-rgb: 255, 255, 255;
14 --background-start-rgb: 0, 0, 0;
15 --background-end-rgb: 0, 0, 0;
16 }
17}
18
19body {
20 color: rgb(var(--foreground-rgb));
21 background: linear-gradient(
22 to bottom,
23 transparent,
24 rgb(var(--background-end-rgb))
25 )
26 rgb(var(--background-start-rgb));
27}
28

Final step, we need to pull in our Tailwind CSS file in our index.html. Update the contents to the following:

1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <link data-trunk rel="rust" data-wasm-opt="z" />
6 <link data-trunk rel="icon" type="image/ico" href="/public/favicon.ico" />
7 <link data-trunk rel="css" href="/public/output.css" />
8 <title>Hello, World!</title>
9 </head>
10
11 <body></body>
12</html>

And that's it! We've now integrated Tailwind CSS into our Leptos App.

Setting up Localization

We're using leptos_i18n for localization in Leptos, which supports an API that's very close to the one we used in Next.js. We already pulled in the dependency when we updated our Cargo.toml file earlier, so let's get the rest of it set up.

We'll create a ui-internal/messages/ folder where our locales will live:

$ mkdir ui-internal/messages

We'll define our first locale, English, in a messages/en.json file:

1{
2 "home": {
3 "intro": "Welcome!"
4 }
5}

And also a French locale, in a messages/fr.json file:

1{
2 "home": {
3 "intro": "Bienvenue!"
4 }
5}

leptos_i18n exposes a macro load_locales!() that looks for our configuration and generates code specific for our project that we can load in our App.

Let's update src/main.rs, and also pull in a new module home in anticipation of creating splitting our code out from the current app.rs file:

1use leptos::*;
2
3mod app;
4mod home;
5
6// Load our locales from the files defined in `Cargo.toml`.
7leptos_i18n::load_locales!();
8
9pub fn main() {
10 // Register log and panich handlers.
11 let _ = console_log::init_with_level(log::Level::Debug);
12 console_error_panic_hook::set_once();
13
14 mount_to_body(|| {
15 view! { <app::Layout /> }
16 });
17}

Let's create a src/home.rs in which will use our locales:

1use crate::i18n::*;
2use leptos::*;
3use leptos_router::*;
4
5#[component]
6pub fn Page() -> impl IntoView {
7 let i18n = use_i18n();
8
9 view! {
10 <div class="grid place-content-center content-center h-screen">
11 <h1 class="text-6xl">{t!(i18n, home.intro)}</h1>
12 <div class="grid gap-4 grid-cols-2">
13 <A href="/fr">"Go to fr"</A>
14 <A href="/en">"Go to en"</A>
15 </div>
16 </div>
17 }
18}

The magic here comes from the crate::i18n::* which got generated by leptos_i18n::load_locales!(), and the use_i18n hook that we now got access to. Very similar to our Next.js App, we then call the macro t! to get the correct translation for the current locale, given a JSON key.

We're not entirely done yet, we need to tell our Leptos App about the I18nContext at the root of our application. We also still need to add support for routing between locales.

Let's update src/app.rs to do this:

1use crate::i18n::*;
2use leptos::*;
3use leptos_meta::*;
4use leptos_router::*;
5
6use crate::home;
7
8const DEFAULT_LOCALE: &str = "en";
9
10#[derive(Params, PartialEq, Clone, Debug)]
11pub struct LayoutParams {
12 locale: String,
13}
14
15#[component(transparent)]
16fn LocalizedRoute<P, F, IV>(path: P, view: F) -> impl IntoView
17where
18 P: std::fmt::Display,
19 F: Fn() -> IV + 'static,
20 IV: IntoView,
21{
22 view! {
23 <Route path=format!("/:locale{}", path) view=move || {
24 // Extract the locale from the path.
25 let i18n = use_i18n();
26 let params = use_params::<LayoutParams>();
27 let chosen_locale = move || params().map(|params| params.locale).unwrap_or(DEFAULT_LOCALE.to_string());
28
29 create_effect(move |_| {
30 // Figure out what the current locale is, and if it matches the chosen locale from path.
31 let current_locale = i18n();
32 let new_locale = match chosen_locale().as_str() {
33 "fr" => Locale::fr,
34 "en" => Locale::en,
35 _ => Locale::en,
36 };
37 // Update the locale if necessary.
38 if current_locale != new_locale {
39 i18n(new_locale);
40 }
41 });
42
43 view! {
44 {view()}
45 }
46 }/>
47 }
48}
49
50#[component]
51pub fn Layout() -> impl IntoView {
52 provide_meta_context();
53 provide_i18n_context();
54
55 view! {
56 <Router>
57 <main>
58 <Routes>
59 <Route path="" view=move || view! { <Redirect path=format!("/{}", DEFAULT_LOCALE)/> }/>
60 <LocalizedRoute path="" view=move || view! { <home::Page/> }/>
61 </Routes>
62 </main>
63 </Router>
64 }
65}

There's a lot to unpack here, so let's go through it step by step.

  • In our pub fn Layout() -> impl IntoView component we set up a normal Router component, which will handle routing for us.
  • We introduce a special route, LocalizedRoute, which handles detecting our locale and switching the active locale if the path changes.
  • In our fn LocalizedRoute function we wrap the normal Route component, and inject a bit of logic before returning the view that was otherwise passed into our LocalizedRoute.

The last part is the most interesting, so let's break down what we are doing inside the Route we are setting up for LocalizedRoute.

First we get the current parameters, which we know will contain a locale key:

1// Extract the locale from the path.
2let i18n = use_i18n();
3let params = use_params::<LayoutParams>();
4let chosen_locale = move || params().map(|params| params.locale).unwrap_or(DEFAULT_LOCALE.to_string());

We then create an effect that will run every time the parameters change, which will be every time the path changes:

1create_effect(move |_| {
2 // Figure out what the current locale is, and if it matches the chosen locale from path.
3 let current_locale = i18n();
4 let new_locale = match chosen_locale().as_str() {
5 "fr" => Locale::fr,
6 "en" => Locale::en,
7 _ => Locale::en,
8 };
9 // Update the locale if necessary.
10 if current_locale != new_locale {
11 i18n(new_locale);
12 }
13});

The thing that makes our effect rerun is our usage of i18n() which subscribes us to the signal, and thus reruns the effect every time the locale changes.

You should now have a structure that looks like this:

.
├── Cargo.lock
├── Cargo.toml
├── Trunk.toml
├── index.html
├── messages
   ├── en.json
   └── fr.json
├── public
   └── favicon.ico
├── resources
   └── input.css
├── src
   ├── app.rs
   ├── home.rs
   └── main.rs
├── tailwind.config.ts

And that's it! We're now able to run our app and check it out in the browser:

$ trunk serve --open
Screenshot of our ui-internal

Again, it may not look like much, but we've implemented a lot of the core functionality we need to get started!

As the final step we will add our commands to just, extending our existing justfile:

1_setup-ui-internal:
2 #!/usr/bin/env bash
3 set -euxo pipefail
4 cd ui-internal
5 rustup toolchain install nightly
6 rustup default nightly
7 rustup target add wasm32-unknown-unknown
8
9_dev-ui-internal:
10 #!/usr/bin/env bash
11 set -euxo pipefail
12 cd ui-internal
13 trunk serve

Bonus: End-to-End Tests

End-to-End tests are a great way to ensure that our App is working as expected, and that we don't accidentally break something when we make changes. We'll use Playwright for this, which is a great tool for writing End-to-End tests.

We want three different test suites to cover:

  • ui-app: Test our Next.js App.
  • ui-internal: Test our Leptos App.
  • deployment.: Test our deployed App to verify it is working as expected.

Let's start by setting up our folder structure. Many of our configuration files will be the same across all three test suites, let's create an end2end folder for our projects:

$ mkdir -p deployment/end2end/tests
$ mkdir -p ui-app/end2end/tests
$ mkdir -p ui-internal/end2end/tests

We intentionally make a distinction between end2end/ and unit/integration tests which will live in tests/. These have very different requirements for how to run them, and we often want to run them at different times.

Before we can run anything, we will need a couple of other files to set up Playwright as well as support for TypeScript.

Let's create a tsconfig.json for all for all three projects (ui-app, ui-internal, and deployment). We'll place it at <project>/end2end/tsconfig.json:

1{
2 "compilerOptions": {
3 "lib": ["esnext"],
4 "module": "esnext",
5 "target": "esnext",
6 "moduleResolution": "bundler",
7 "noEmit": true,
8 "allowImportingTsExtensions": true,
9 "moduleDetection": "force",
10 "allowJs": true, // allow importing `.js` from `.ts`
11 "esModuleInterop": true, // allow default imports for CommonJS modules
12 "strict": true,
13 "forceConsistentCasingInFileNames": true,
14 "skipLibCheck": true,
15 "noImplicitAny": false
16 }
17}

Now, let's configure Playwright for all for all three projects (ui-app, ui-internal, and deployment). We'll place it at <project>/end2end/playwright.config.ts:

1import type { PlaywrightTestConfig } from "@playwright/test";
2import { devices } from "@playwright/test";
3
4const SERVER = `http://localhost:8080`;
5
6const config: PlaywrightTestConfig = {
7 testDir: "./tests",
8 // Maximum time one test can run for.
9 timeout: 30 * 1000,
10 expect: {
11 /**
12 * Maximum time expect() should wait for the condition to be met.
13 * For example in `await expect(locator).toHaveText();`
14 */
15 timeout: 5000,
16 },
17 // Run tests in files in parallel.
18 fullyParallel: true,
19 // Fail the build on CI if you accidentally left test.only in the source code.
20 forbidOnly: !!process.env.CI,
21 // Retry on CI only.
22 retries: process.env.CI ? 2 : 0,
23 // [Optional] Opt out of parallel tests on CI.
24 // workers: process.env.CI ? 1 : undefined,
25 // Limit the number of failures on CI to save resources
26 maxFailures: process.env.CI ? 10 : undefined,
27
28 reporter: "html",
29 use: {
30 // Base URL to use in actions like `await page.goto('/')`.
31 baseURL: SERVER,
32 // Maximum time each action such as `click()` can take. Defaults to 0 (no limit).
33 actionTimeout: 0,
34 // Collect trace when retrying the failed test.
35 trace: "on-first-retry",
36 },
37
38 // Configure which browsers to test against.
39 projects: [
40 {
41 name: "chromium",
42 use: {
43 ...devices["Desktop Chrome"],
44 },
45 },
46 ],
47 webServer: {
48 command: "just dev ui-internal",
49 url: SERVER,
50 reuseExistingServer: true,
51 stdout: "ignore",
52 stderr: "pipe",
53 },
54};
55
56export default config;

There are some minor adjustments we want to do in the above configuration for each project:

  • ui-app: Set the SERVER variable to http://localhost:3000 and command to just dev ui-app.
  • ui-internal: Set the SERVER variable to http://localhost:8080 and command to just dev ui-internal.
  • deployment: Remove the webServer block, and set the SERVER variable to http://${process.env.DOMAIN}.

And a package.json in <project>/end2end/package.json:

1{
2 "name": "end2end",
3 "version": "1.0.0",
4 "description": "",
5 "main": "index.js",
6 "scripts": {
7 "setup": "playwright install",
8 "e2e": "playwright test"
9 },
10 "keywords": [],
11 "author": "",
12 "license": "ISC",
13 "devDependencies": {
14 "bun-types": "latest",
15 "@types/node": "^20.5.0",
16 "typescript": "^5",
17 "@playwright/test": "^1.38.1"
18 }
19}

We are now ready to add our first test! Since we have just added localization, let's make sure that it works and doesn't regress.

For all three projects ui-app, deployment, and ui-internal we'll create a test in <project>/end2end/tests/localization.spec.ts:

1import { test, expect } from "@playwright/test";
2
3test("localization translates text when changing language", async ({ page }) => {
4 await page.goto("/");
5 await expect(page.locator("h1")).toHaveText(
6 "Welcome!"
7 );
8
9 await page.getByText('Go to fr').dblclick();
10 await expect(page.locator("h1")).toHaveText(
11 "Bienvenue!"
12 );
13});
14
15test("localization loads correct text from URL", async ({ page }) => {
16 await page.goto("/fr");
17 await expect(page.locator("h1")).toHaveText(
18 "Bienvenue!"
19 );
20});

This same test works for both apps since we've set them up with the same functionality.

Let's try and run them:

$ cd ui-app/end2end # or cd ui-internal/end2end
$ bun run e2e

And for deployment we can test it locally by starting up just dev ui-app in another terminal, and then running:

$ cd deployment/end2end # or cd ui-internal/end2end
$ DOMAIN="localhost:3000" bun run e2e

NOTE: You might want to add the following to your .gitignore:

1playwright-report
2test-results

And that's it! We've now got an easy way to run End-to-End tests. Let's do our final step and add this to our justfile:

1# Run End-to-End tests for <project>, e.g. `just e2e ui-internal`.
2e2e project:
3 just _e2e-{{project}}
4
5_e2e-deployment:
6 cd deployment/end2end && bun run e2e
7
8_e2e-ui-app:
9 cd ui-app/end2end && bun run e2e
10
11_e2e-ui-internal:
12 cd ui-internal/end2end && bun run e2e

And we'll also update our _setup-project commands to setup the Playwright dependencies:

1_setup-deployment:
2 #!/usr/bin/env bash
3 set -euxo pipefail
4 cd deployment
5 bun install
6 cd end2end
7 bun install
8 bun run setup
9
10_setup-ui-app:
11 #!/usr/bin/env bash
12 set -euxo pipefail
13 cd ui-app
14 bun install
15 cd end2end
16 bun install
17 bun run setup
18
19_setup-ui-internal:
20 #!/usr/bin/env bash
21 set -euxo pipefail
22 cd ui-internal
23 rustup toolchain install nightly
24 rustup default nightly
25 rustup target add wasm32-unknown-unknown
26 cd end2end
27 bun install
28 bun run setup

Bonus: DevEx Improvements

There are a few Editor improvements that are recommended for working with Leptos and Tailwind CSS if you are using VS Code.

Add to your settings:

1{
2 "rust-analyzer.procMacro.ignored": {
3 "leptos_macro": ["server", "component"]
4 },
5 "emmet.includeLanguages": {
6 "rust": "html",
7 "*.rs": "html"
8 },
9 "tailwindCSS.includeLanguages": {
10 "rust": "html",
11 "*.rs": "html"
12 },
13 "files.associations": {
14 "*.rs": "rust"
15 },
16 "editor.quickSuggestions": {
17 "other": "on",
18 "comments": "on",
19 "strings": true
20 },
21 "css.validate": false
22}

Another nice tool is leptosfmt, which helps keep our Leptos View macro code nicely formatted.

You can install it via:

$ cargo install leptosfmt

And then add this to your settings:

1{
2 "rust-analyzer.rustfmt.overrideCommand": ["leptosfmt", "--stdin", "--rustfmt"]
3}

Automating Deployments via CDK

Since we are now generating artifacts that we want to deploy, from multiple projects, we need to restructure our existing deployment pipeline slightly.

We still want to retain staggered deployments, but we need a bit of coordination to make sure we have all relevant artifacts before we deploy.

Our new flow will look like this:

Updated deployment pipeline

Building artifacts in CI

In this part we will be doing the following:

  • Extend our existing cd-deploy.yml workflow to build artifacts for ui-app and ui-internal via a reuseable workflow.
  • Extend our existing wf-deploy.yml workflow to download artifacts so it can use it during deployments.
  • Set up a reuseable workflow, wf-build.yml, that will build our artifacts.
  • Set up a reuseable workflows for both ui-app and ui-internal that will do the actual building.

Let's start with our wf-build-ui-app.yml workflow:

1name: "Build: ui-app"
2
3on:
4 workflow_call:
5 inputs:
6 release:
7 type: boolean
8 default: false
9 upload-artifact:
10 type: boolean
11 default: false
12
13jobs:
14 ui-app:
15 runs-on: ubuntu-latest
16 steps:
17 - uses: actions/checkout@v3
18 - uses: actions/setup-node@v3
19 with:
20 node-version: 20
21 - uses: oven-sh/setup-bun@v1
22 - uses: extractions/setup-just@v1
23
24 - name: Install dependencies
25 working-directory: ./ui-app
26 run: bun install
27
28 - name: Build Next.js project
29 run: just build ui-app
30
31 - uses: actions/upload-artifact@v3
32 if: ${{ inputs.upload-artifact }}
33 with:
34 name: ui-app
35 path: ui-app/out
36 if-no-files-found: error
37 retention-days: 1

And our wf-build-ui-internal.yml workflow:

1name: "Build: ui-internal"
2
3on:
4 workflow_call:
5 inputs:
6 release:
7 type: boolean
8 default: false
9 upload-artifact:
10 type: boolean
11 default: false
12
13jobs:
14 ui-internal:
15 runs-on: ubuntu-latest
16 steps:
17 - uses: actions/checkout@v3
18 - uses: oven-sh/setup-bun@v1
19 - uses: extractions/setup-just@v1
20 - name: Install Rust toolchain
21 uses: dtolnay/rust-toolchain@nightly
22 with:
23 toolchain: nightly-2023-10-11
24 targets: wasm32-unknown-unknown
25
26 - name: Install trunk
27 uses: jaxxstorm/[email protected]
28 with:
29 repo: thedodd/trunk
30 platform: linux # Other valid options: 'windows' or 'darwin'.
31 arch: x86_64
32
33 - uses: actions/cache@v3
34 continue-on-error: false
35 with:
36 path: |
37 ~/.cargo/registry
38 ~/.cargo/git
39 ui-internal/target
40 key: cargo-${{ hashFiles('Cargo.lock') }}-${{ hashFiles('ui-internal/Cargo.lock') }}-ui-internal
41 restore-keys: cargo-
42
43 - name: Build release build
44 if: ${{ inputs.release }}
45 run: just build ui-internal
46
47 - name: Build debug build
48 if: ${{ !inputs.release }}
49 run: just build ui-internal true
50
51 - uses: actions/upload-artifact@v3
52 if: ${{ inputs.upload-artifact }}
53 with:
54 name: ui-internal
55 path: ui-internal/dist
56 if-no-files-found: error
57 retention-days: 1

Both of these workflows take two optional arguments:

  • release: Whether or not to build a release build.
  • upload-artifact: Whether or not to upload the artifact to GitHub.

This means we can easily reuse these builds from our CI workflows. Once our jobs are building, we'll see these artifacts in the Summary view of our workflow, which will look something like this:

Deployment artifacts

With these in place we can now stitch them together in a wf-build.yml:

1name: "Build Artifacts"
2
3on:
4 workflow_call:
5
6jobs:
7 ui-app:
8 uses: ./.github/workflows/wf-build-ui-app.yml
9 with:
10 release: true
11 upload-artifact: true
12
13 ui-internal:
14 uses: ./.github/workflows/wf-build-ui-internal.yml
15 with:
16 release: true
17 upload-artifact: true

Not much going on here, we are simply calling our previously defined reuseable workflows.

We can now update our cd-deploy.yml workflow to call our new wf-build.yml workflow. To do this, we extend the existing file by adding a build-artifacts job as well as mark our stage-1 job as needs: [build-artifacts]:

1# ...
2jobs:
3 # Build deployment artifacts
4 build-artifacts:
5 name: "Build Artifacts"
6 uses: ./.github/workflows/wf-build.yml
7
8 # Stage 1 tests that the deployment is working correctly.
9 stage-1:
10 name: "Stage 1"
11 needs: [build-artifacts]
12 uses: ./.github/workflows/wf-deploy.yml
13 # ...the rest is the same

The final change we need to make is to make our wf-deploy.yml workflow download the artifacts we just built:

1# ...
2jobs:
3 deploy:
4 name: "Deploy"
5 runs-on: ubuntu-latest
6 environment: ${{ inputs.environment }}
7 # Limit to only one concurrent deployment per environment.
8 concurrency:
9 group: ${{ inputs.environment }}
10 cancel-in-progress: false
11 steps:
12 - uses: actions/checkout@v3
13 - uses: oven-sh/setup-bun@v1
14 - uses: extractions/setup-just@v1
15
16 - name: Setup artifact directory
17 run: mkdir -p ./deployment/artifacts
18
19 # Download all artifacts from previous jobs.
20 - uses: actions/download-artifact@v3
21 with:
22 path: ./deployment/artifacts/
23
24 - name: Display structure of downloaded files
25 working-directory: ./deployment/artifacts/
26 run: ls -R
27
28 - name: Validate artifacts
29 working-directory: ./deployment/artifacts
30 run: just deploy-validate-artifacts
31
32 - name: Install dependencies
33 working-directory: ./deployment
34 run: bun install
35 # ...the rest is the same

The new additions here are the steps:

  • extractions/setup-just@v1: Make just available in our workflow.
  • Setup artifact directory: Creating our artifacts folder.
  • actions/download-artifact@v3y: Download all uploaded assets into this folder.
  • Display structure of downloaded files: Very helpful for any debugging.
  • Validate artifacts: Make sure we have all the artifacts we need.

The deploy-validate-artifacts command is defined in our justfile:

1# Validate that all deployment artifacts are present.
2deploy-validate-artifacts:
3 @ [ -d "./deployment/artifacts/ui-app" ] && echo "ui-app exists" || exit 1
4 @ [ -d "./deployment/artifacts/ui-internal" ] && echo "ui-internal exists" || exit 1

Deploying to S3 + CloudFront

If we remember our three groupings, we see that ui-app and ui-internal would fit into Services:

  • Global: "Global" (often us-east-1) specific things such as ACM Certificates for CloudFront, and we'll also put Hosted Zones here
  • Cloud: Region specific infrequently changing things such as VPC, Region-specific Certificates, etc
  • Platform: DynamoDB, Cache, SQS
  • Services: Lambdas, API Gateway, etc
Preparing for CloudFront

We'd like our CloudFront distribution to use its own domain name and HTTPS, and to do this requires a bit of extra work on our end, since CloudFront needs our ACM Certificate to live in us-east-1 specifically.

This means that it will fit into our Global stack. Cross-stack references in CDK/CloudFormation is a bit finicky, and we generally want to avoid relying on Exports which are incredibly frustrating to work with and often gets you into sticky situations where you have to carefully destroy or update your stacks in a certain order. Once an export is used, it cannot change, leading to very tight coupling between stacks.

Instead, we will rely on the approach outlined in this nice article from AWS on how to Read parameters across AWS Regions with AWS CloudFormation custom resources.

We will essentially:

  • Store the Certificate ARN in the SSM Parameter Store in us-east-1.
  • Set up a new construct via AwsCustomResource and AwsSdkCall that can read parameters from a specific region.
  • Use this construct in our Services stack to read the Certificate ARN from us-east-1.

Let's set up our new Certificate first. We'll adjust the existing GlobalStack slightly in bin/deployment.ts:

1/**
2 * SSM Parameter name for the global certificate ARN used by CloudFront.
3 */
4const GLOBAL_CERTIFICATE_SSM = "/global/acm/certificate/arn";
5
6/**
7 * Define our 'Global' stack that provisions the infrastructure for our application, such
8 * as domain names, certificates, and other resources that are shared across all regions.
9 *
10 * ```bash
11 * bun run cdk deploy --concurrency 6 'Global/**'
12 * ```
13 */
14const globalStackName = "Global";
15if (matchesStack(app, globalStackName)) {
16 // Some of our global resources need to live in us-east-1 (e.g. CloudFront certificates),
17 // so we set that as the region for all global resources.
18 new GlobalStack(app, globalStackName, {
19 env: {
20 account: process.env.AWS_ACCOUNT_ID || process.env.CDK_DEFAULT_ACCOUNT,
21 region: "us-east-1",
22 },
23 domain: validateEnv("DOMAIN", globalStackName),
24 certificateArnSsm: GLOBAL_CERTIFICATE_SSM,
25 });
26}

We've introduced GLOBAL_CERTIFICATE_SSM which will be how we share the name of the parameter across stacks, and certificateArnSsm as a property to our GlobalStack.

Let's set up the certificate before we stitch it into our GlobalStack. We'll create a new file lib/global/certificate.ts:

1import * as cdk from 'aws-cdk-lib';
2import { Construct } from 'constructs';
3import * as route53 from 'aws-cdk-lib/aws-route53';
4import * as acm from 'aws-cdk-lib/aws-certificatemanager';
5import * as ssm from 'aws-cdk-lib/aws-ssm';
6
7export interface StackProps extends cdk.StackProps {
8 /**
9 * The domain name the application is hosted under.
10 */
11 readonly domain: string;
12
13 /**
14 * SSM Parameter name for the global certificate ARN used by CloudFront.
15 */
16 readonly certificateArnSsm: string;
17}
18
19export class Stack extends cdk.Stack {
20 constructor(scope: Construct, id: string, props: StackProps) {
21 super(scope, id, props);
22
23 const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
24 domainName: props.domain,
25 });
26
27 // Set up an ACM certificate for the domain + subdomains, and validate it using DNS.
28 // NOTE: This has to live in us-east-1 for CloudFront to be able to use it with CloudFront.
29 const cert = new acm.Certificate(this, 'Certificate', {
30 domainName: props.domain,
31 subjectAlternativeNames: [`*.${props.domain}`],
32 validation: acm.CertificateValidation.fromDns(hostedZone),
33 });
34
35 new cdk.CfnOutput(this, `CertificateArn`, {
36 value: cert.certificateArn,
37 description: 'The ARN of the ACM Certificate to be used with CloudFront.',
38 });
39
40 // Store the Certificate ARN in SSM so that we can reference it from other regions
41 // without creating cross-stack references.
42 new ssm.StringParameter(this, 'CertificateARN', {
43 parameterName: props.certificateArnSsm,
44 description: 'Certificate ARN to be used with Cloudfront',
45 stringValue: cert.certificateArn,
46 });
47 }
48}

The last step in the stack stores the certificateArn in the SSM Parameter Store.

Finally, we adjust lib/global/stack.ts to now look like:

1import * as cdk from 'aws-cdk-lib';
2import { Construct } from 'constructs';
3import * as domain from './domain';
4import * as certificate from './certificate';
5
6interface StackProps extends cdk.StackProps, domain.StackProps, certificate.StackProps {}
7
8export class Stack extends cdk.Stack {
9 constructor(scope: Construct, id: string, props: StackProps) {
10 super(scope, id, props);
11
12 // Set up our domain stack.
13 const domainStack = new domain.Stack(this, 'Domain', props);
14
15 // Set up our Certificate stack.
16 const certificateStack = new certificate.Stack(this, 'Certificate', props);
17 certificateStack.addDependency(domainStack);
18 }
19}

Instead of passing the Hosted Zone into the certificate stack, we explicitly mark the certificate as dependent on the domain stack to ensure the hosted zone exists before we try to access it. Again, avoiding exports.

Normally SSM doesn't take the region as a parameter, so to access the parameter from us-east-1 we'll set up a new construct in lib/services/ssm-global.ts:

1import { Arn, Stack } from 'aws-cdk-lib';
2import * as CustomResource from 'aws-cdk-lib/custom-resources';
3import { Construct } from 'constructs';
4
5interface SsmGlobalProps {
6 /**
7 * The name of the parameter to retrieve.
8 */
9 parameterName: string;
10
11 /**
12 * The region the parameter is stored in, when it was created.
13 */
14 region: string;
15}
16
17/**
18 * Remove any leading slashes from the resource `parameterName`.
19 */
20const removeLeadingSlash = (parameterName: string): string =>
21 parameterName.slice(0, 1) == '/' ? parameterName.slice(1) : parameterName;
22
23/**
24 * Custom resource to retrieve a global SSM parameter. See https://aws.amazon.com/blogs/infrastructure-and-automation/read-parameters-across-aws-regions-with-aws-cloudformation-custom-resources/ for more information.
25 *
26 * You store your SSM Parameter as normal in any region:
27 * ```ts
28 * import { StringParameter } from 'aws-cdk-lib/aws-ssm';
29 *
30 * const cert = ...
31 *
32 * new StringParameter(this, 'CertificateARN', {
33 * parameterName: 'CloudFrontCertificateArn',
34 * description: 'Certificate ARN to be used with Cloudfront',
35 * stringValue: cert.certificateArn,
36 * });
37 * ```
38 *
39 * Example of retrieving it from another region:
40 * ```ts
41 * import { SsmGlobal } from './ssm-global';
42 *
43 * const certificateArnReader = new SsmGlobal(this, 'SsmCertificateArn', {
44 * parameterName: "CloudFrontCertificateArn",
45 * region: 'us-east-1'
46 * });
47 *
48 * // Get the value itself.
49 * certificateArnReader.value();
50 * ```
51 */
52export class SsmGlobal extends CustomResource.AwsCustomResource {
53 constructor(scope: Construct, name: string, props: SsmGlobalProps) {
54 const { parameterName, region } = props;
55
56 const ssmAwsSdkCall: CustomResource.AwsSdkCall = {
57 service: 'SSM',
58 action: 'getParameter',
59 parameters: {
60 Name: parameterName,
61 },
62 region,
63 physicalResourceId: CustomResource.PhysicalResourceId.of(Date.now().toString()),
64 };
65
66 const ssmCrPolicy = CustomResource.AwsCustomResourcePolicy.fromSdkCalls({
67 resources: [
68 Arn.format(
69 {
70 service: 'ssm',
71 region: props.region,
72 resource: 'parameter',
73 resourceName: removeLeadingSlash(parameterName),
74 },
75 Stack.of(scope),
76 ),
77 ],
78 });
79
80 super(scope, name, { onUpdate: ssmAwsSdkCall, policy: ssmCrPolicy });
81 }
82
83 /**
84 * Get the parameter value from the store.
85 */
86 public value(): string {
87 return this.getResponseField('Parameter.Value').toString();
88 }
89}

We now have everything we need to create our services.

Services

Now we are ready to get our Services stack set up!

All files will live in the deployment/ folder. We'll first adjust our bin/deployment.ts, adding our Services stack. Append the following at the end:

1// ...
2import { Stack as ServicesStack } from "../lib/services/stack";
3// ...
4
5/**
6 * Define our 'Services' stack that provisions our applications and services, such as our
7 * UI applications and APIs.
8 *
9 * ```bash
10 * bun run cdk deploy --concurrency 6 'Services/**'
11 * ```
12 */
13const servicesStackName = "Services";
14if (matchesStack(app, servicesStackName)) {
15 // Set up our service resources.
16 new ServicesStack(app, servicesStackName, {
17 env: {
18 account: process.env.AWS_ACCOUNT_ID || process.env.CDK_DEFAULT_ACCOUNT,
19 region:
20 process.env.AWS_REGION ||
21 process.env.AWS_DEFAULT_REGION ||
22 process.env.CDK_DEFAULT_REGION,
23 },
24 domain: validateEnv("DOMAIN", servicesStackName),
25 certificateArnSsm: GLOBAL_CERTIFICATE_SSM,
26 });
27}

And our ServicesStack is defined in lib/services/stack.ts:

1import * as cdk from "aws-cdk-lib";
2import { Construct } from "constructs";
3
4import * as s3Website from "./s3-website";
5import { SsmGlobal } from './ssm-global';
6
7interface StackProps extends cdk.StackProps {
8 /**
9 * The domain name the application is hosted under.
10 */
11 readonly domain: string;
12
13 /**
14 * The ACM Certificate ARN to use with CloudFront.
15 */
16 readonly certificate: acm.Certificate;
17}
18
19export class Stack extends cdk.Stack {
20 constructor(scope: Construct, id: string, props: StackProps) {
21 super(scope, id, props);
22
23 // Fetch the ARN of our CloudFront ACM Certificate from us-east-1.
24 const certificateArnReader = new SsmGlobal(this, 'SsmCertificateArn', {
25 parameterName: props.certificateArnSsm,
26 region: 'us-east-1',
27 });
28 const certificateArn = certificateArnReader.value();
29
30 // Set up our s3 website for ui-app.
31 new s3Website.Stack(this, "UiApp", {
32 ...props,
33 assets: "artifacts/ui-app",
34 index: "index.html",
35 error: "404.html",
36 domain: props.domain,
37 hostedZone: props.domain,
38 billingGroup: "ui-app",
39 rewriteUrls: true,
40 certificateArn: certificateArn,
41 });
42
43 // Set up our s3 website for ui-internal.
44 new s3Website.Stack(this, "UiInternal", {
45 ...props,
46 assets: "artifacts/ui-internal",
47 index: "index.html",
48 error: "index.html",
49 domain: `internal.${props.domain}`,
50 hostedZone: props.domain,
51 billingGroup: "ui-internal",
52 certificateArn: certificateArn,
53 });
54 }
55}

In here we deploy both ui-app and ui-internal the same way, but do some minor adjustments to the props we pass on to the stack to ensure it gets the right assets and also the right domain.

This brings us to our final part, which is the most lengthy, our lib/services/s3-website.ts:

1import * as cdk from "aws-cdk-lib";
2import { Construct } from "constructs";
3import * as route53 from "aws-cdk-lib/aws-route53";
4import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
5import * as acm from "aws-cdk-lib/aws-certificatemanager";
6import * as lambda from "aws-cdk-lib/aws-lambda";
7import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
8import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
9import * as cloudfrontOrigins from "aws-cdk-lib/aws-cloudfront-origins";
10import * as s3 from "aws-cdk-lib/aws-s3";
11import * as path from "path";
12
13export interface StackProps extends cdk.StackProps {
14 /**
15 * The path to the assets we are deploying.
16 */
17 readonly assets: string;
18
19 /**
20 * The file to use as the root/index page.
21 */
22 readonly index: string;
23
24 /**
25 * The file to redirect upon errors or 404s.
26 */
27 readonly error: string;
28
29 /**
30 * The domain name the application is hosted under.
31 */
32 readonly domain: string;
33
34 /**
35 * The hosted zone that controls the DNS for the domain.
36 */
37 readonly hostedZone: string;
38
39 /**
40 * The billing group to associate with this stack.
41 */
42 readonly billingGroup: string;
43
44 /**
45 * The ACM Certificate ARN.
46 */
47 readonly certificateArn: string;
48
49 /**
50 * Whether to rewrite URLs to /folder/ -> /folder/index.html.
51 */
52 readonly rewriteUrls?: boolean;
53}
54
55/**
56 * Set up an S3 bucket, hosting our assets, with CloudFront in front of it.
57 */
58export class Stack extends cdk.Stack {
59 constructor(scope: Construct, id: string, props: StackProps) {
60 super(scope, id, props);
61
62 // Create our S3 Bucket, making it private and secure.
63 const bucket = new s3.Bucket(this, "WebsiteBucket", {
64 publicReadAccess: false,
65 accessControl: s3.BucketAccessControl.PRIVATE,
66 blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
67 encryption: s3.BucketEncryption.S3_MANAGED,
68 removalPolicy: cdk.RemovalPolicy.RETAIN,
69 });
70 cdk.Tags.of(bucket).add("billing", `${props.billingGroup}-s3`);
71 cdk.Tags.of(bucket).add("billing-group", `${props.billingGroup}`);
72
73 // Set up access between CloudFront and our S3 Bucket.
74 const originAccessIdentity = new cloudfront.OriginAccessIdentity(
75 this,
76 "OriginAccessIdentity"
77 );
78 bucket.grantRead(originAccessIdentity);
79
80 // Rewrite requests to /folder/ -> /folder/index.html.
81 let rewriteUrl: cloudfront.experimental.EdgeFunction | undefined;
82 if (props.rewriteUrls) {
83 rewriteUrl = new cloudfront.experimental.EdgeFunction(this, "RewriteFn", {
84 runtime: lambda.Runtime.NODEJS_LATEST,
85 handler: "rewrite-urls.handler",
86 code: lambda.Code.fromAsset(path.resolve("edge-functions")),
87 });
88 }
89
90 // Configure our CloudFront distribution.
91 const distribution = new cloudfront.Distribution(this, "Distribution", {
92 domainNames: [props.domain],
93 certificate: acm.Certificate.fromCertificateArn(
94 this,
95 "Certificate",
96 props.certificateArn
97 ),
98 // Allow both HTTP 2 and 3.
99 httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
100 // Our default behavior is to redirect to our index page.
101 defaultRootObject: props.index,
102 defaultBehavior: {
103 // Set our S3 bucket as the origin.
104 origin: new cloudfrontOrigins.S3Origin(bucket, {
105 originAccessIdentity,
106 }),
107 // Redirect users from HTTP to HTTPs.
108 viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
109 edgeLambdas:
110 rewriteUrl !== undefined
111 ? [
112 {
113 functionVersion: rewriteUrl.currentVersion,
114 eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
115 },
116 ]
117 : undefined,
118 },
119 // Set up redirects when a user hits a 404 or 403.
120 errorResponses: [
121 {
122 httpStatus: 403,
123 responsePagePath: `/${props.error}`,
124 responseHttpStatus: 200,
125 },
126 {
127 httpStatus: 404,
128 responsePagePath: `/${props.error}`,
129 responseHttpStatus: 200,
130 },
131 ],
132 });
133 cdk.Tags.of(distribution).add(
134 "billing",
135 `${props.billingGroup}-cloudfront`
136 );
137 cdk.Tags.of(distribution).add("billing-group", `${props.billingGroup}`);
138
139 // Upload our assets to our bucket, and connect it to our distribution.
140 new s3deploy.BucketDeployment(this, "WebsiteDeployment", {
141 destinationBucket: bucket,
142 sources: [s3deploy.Source.asset(path.resolve(props.assets))],
143 // Invalidate the cache for / and index.html when we deploy so that cloudfront serves latest site
144 distribution,
145 distributionPaths: ["/", `/${props.index}`],
146 });
147
148 // Set up our DNS records that points to our CloudFront distribution.
149 const hostedZone = route53.HostedZone.fromLookup(this, "HostedZone", {
150 domainName: props.hostedZone,
151 });
152
153 new route53.ARecord(this, "Alias", {
154 zone: hostedZone,
155 recordName: props.domain,
156 target: route53.RecordTarget.fromAlias(
157 new route53Targets.CloudFrontTarget(distribution)
158 ),
159 });
160 }
161}

And our Lambda@Edge function to rewrite urls is defined in edge-functions/rewrite-urls.js:

1exports.handler = (event, _, callback) => {
2 let request = event.Records[0].cf.request;
3
4 // Check whether the URI is missing a file name.
5 if (request.uri.endsWith("/")) {
6 request.uri += "index.html";
7 } else if (!request.uri.includes(".")) {
8 // Check whether the URI is missing a file extension.
9 request.uri += "/index.html";
10 }
11
12 return callback(null, request);
13};

There is quite a bit going on here. A rough overview of what is happening:

  • We create an S3 Bucket, which will host our assets.
  • The S3 Bucket will be configured to with encryption and to block public read access.
  • We create a CloudFront distribution, which will serve our assets.
  • The CloudFront distribution will also redirect HTTP to HTTPS, use our domain name and certificate, as well as support HTTP 2 and 3.
  • If enabled via rewriteUrls, we will also set up a Lambda@Edge function that will rewrite URLs to /folder/ -> /folder/index.html. This is necessary to support the way Next.js generates its files.

And that's it! Your ui-app will now live at the root of your domain, e.g. app.example.com, and ui-internal will live at the subdomain internal e.g. internal.app.example.com.

Next Steps

Next up is to set up our Federated GraphQL API! Follow along in Part 4 of the series when that arrives!.

👉 Let me know what you think over on Medium or in the comments below 👇