Skip to content

Latest commit

 

History

History
1287 lines (970 loc) · 42.7 KB

ch7.md

File metadata and controls

1287 lines (970 loc) · 42.7 KB

🚀 Understanding Astro

By Ohans Emmanuel


Chapter 7: Be Audible! (Fullstack Astro Project)

… People will believe what they see. Let them see.
― Henry David Thoreau

In this chapter, I’ll employ you to see beyond static apps and build fullstack applications with Astro.







What you’ll learn

  • The ability to add authentication to an Astro application.
  • An understanding of setting up a backend for an Astro application.
  • A working knowledge of handling form submissions without dedicated API routes.
  • Hands-on experience uploading and retrieving data in an Astro application.
  • An understanding of the kind of apps you can build with Astro.

Project setup

We’ve seen how to build static sites with Astro. So, to make this section laser-focused on scripting and Astro features, I’ve set up a static site for us to work on in this chapter.

The site has been stripped of any relevant functionality. We will build those step-by-step together.

Start by cloning the project:

git clone https://github.com/understanding-astro/fullstack-astro

Change directories:

cd fullstack-astro

You should be on the clean-slate branch by default. Otherwise, check out to clean-slate.

Next, install dependencies and start the application:

npm install && npm run start

The application should successfully run on one of the local server ports.

The BeAudible app initialised

The BeAudible app initialised.


Project overview

Our application is for a hypothetical startup, BeAudible, whose mission is to discover the voices of the world.

In technical terms, BeAudible lets authorised users create audio recordings, upload them to their servers, and have a timeline where people can listen to everyone’s recordings.

An overview of the BeAudible application

An overview of the BeAudible application.


The project we just cloned will receive and upload a user’s recording and eventually display every recording on a shared timeline.

Let’s explore the pages in the project.

The homepage

Firstly, consider the homepage, i.e., the base route /.

The sections of the BeAudible application

The sections of the BeAudible application.


  1. The navigation bar holds a feedback form for users to send their thoughts.
  2. The navigation bar includes a record link to navigate to a dedicated page for recording a user’s audio.
  3. The navigation bar contains a sign-out button. By implication, the homepage should be protected, i.e., only authenticated users should land here.
  4. Finally, in the centre of the page lies the timeline that should list all users’ recordings.

The record page

If you click “Record” from the navigation bar, you will be navigated to the /record route where a user can record their audio.

The record page

The record page.


A React component hydrated in the Astro application powers the recording user interface element.

The signup page

Now, go to the /signup route.

The sign up page

The sign up page.


This is the page to sign up users to BeAudible!

The sign-in page

Finally, visit the /signin route.

The signin page

The signin page.


This is the page for previously authenticated users to log in to the application.

Go ahead and kill the running application from the terminal. Then, we’ll continue with some setup.

Helper components and utilities

To ensure our focus remains on Astro, I created UI components and stored them in the src/components folder. We will import and use these components to develop our solution as we proceed.

Similarly, constants have been stored in src/constants and utility scripts in src/scripts. We aim to concentrate on the critical objective of this chapter, which is to build a fullstack application with Astro.

Technology choices

  1. Firebase as a backend service: we can choose any backend service with Astro, but we’ll use Firebase for simplicity. The principles we’ll discuss work with any other preferred service. We will leverage Firebase’s authentication and cloud storage services.
  2. Tailwind for styling: Tailwind is famous for styling applications. Instead of writing the styles manually, the project uses Tailwind.
  3. Astro as the primary web framework: Of course, the web framework of choice for our application is Astro. No questions asked! However, we will also leverage React components for islands of interactivity.

Backend setup

Let’s point our attention to setting up our backend server. Remember, we will use Firebase as our backend service.

Go to the Firebase homepage and visit the Firebase console.

The Firebase homepage

The Firebase homepage.


The process is much smoother if you have (and are signed in to) a Google account (e.g., Gmail).

Next, create a new Firebase project.

Creating a new Firebase project

Creating a new Firebase project.


Name the project BeAudible and choose whether to use Google Analytics in the project.

Choosing Google analytics and creating the project

Choosing Google analytics and creating the project.


After successfully creating the project, add a web application to the Firebase project.

Adding a web application to the Firebase project

Adding a web application to the Firebase project.


Now, continue the web app set-up process by choosing a name (preferably the same as before), setup Firebase hosting and registering the web application.

Continuing the application set-up

Continuing the application set-up.


The next step is critical.

Copy your web app’s Firebase configuration. We’ll use that to initialise the Firebase application client side.

Copying the Firebase configuration for the client SDK

Copying the Firebase configuration for the client SDK.


The next steps are optional. Follow the guided prompt from Firebase and continue to the Firebase console.

Following the guided prompt from Firebase

Following the guided prompt from Firebase.


Upon completion, we’ll be redirected to the Firebase application dashboard.

Go to the project settings, find the service account section and generate a new private key we’ll leverage in our server application.

Project overview > Project settings

Project overview > Project settings.


Generating a new private key

Generating a new private key.


This will download a JSON file to your machine. Keep it secure as it provides access to Firebase’s service. We will leverage this to access Firebase’s server resources from our application server.

Handling authentication

Generally speaking, authentication is serious business and can take different forms.

Firebase provides an authentication service, so we will leverage its client libraries to authenticate the user client-side.

Simplified authentication process

Simplified authentication process.


The client authentication will communicate with Firebase’s servers, but later on, we will look at verifying a user’s authentication token (JWT) on our server.

First, set up the Firebase application to receive client authentication requests.

Return to the Firebase console and set up authentication.

Select authentication from the list of provided services

Select authentication from the list of provided services.


Firebase provides different sign-in methods. Let’s keep this simple. Enable the Email and password method from the Firebase console.

Selecting the email / password sign-in method

Selecting the email / password sign-in method.


Make sure to enable the option and hit save.

Enabling and saving the Email / Password sign-in method

Enabling and saving the Email / Password sign-in method.


Initialising firebase on the client

src/scripts/firebase/init.ts contains the initialisation script for our client application.

The code responsible for initialising the application is shown below:

// ...
// 📂 src/scripts/firebase/init.ts
export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);

The script exports the initialised application via app and the authentication client module via auth where initializeApp and getAuth are methods imported from the Firebase SDK.

We must now replace the firebaseConfig variable with the object copied while initialising the firebase application.

The firebase client configuration

The firebase client configuration.


Once this is done, we should have the Firebase client rightly initialised.

Using the Firebase emulators

Talking to the production firebase services while testing and developing locally is rather silly.

Sending requests to the production Firebase servers while developing locally

Sending requests to the production Firebase servers while developing locally.


Instead, we can use the Firebase Emulator Suite while developing locally. The emulator suite will intercept our Firebase service requests and provide a testing ground locally without hitting the production services.

I’ve set up the project to use the Firebase emulators. So let’s get it running.

Make sure you have the Firebase CLI tools installed. If you don’t, install the CLI via the following command:

npm install -g firebase-tools

Assuming you have the application running in one tab of your terminal, open another tab and run the firebase emulators script to start the firebase emulators:

npm run emulators

This will start the authentication and storage emulators with a user interface running on localhost:4001. We can view the development data in the emulator user interface, e.g., application user signups and uploaded recordings.

Starting the Firebase emulators

Starting the Firebase emulators.


Handling user signups

So, how are we going to handle user signups?

Please consider the overall flow diagram below:

The signup flow

The signup flow.


  • The flow kicks off with the user submitting the signup form.
  • Then check if the submitted email and password are valid.
  • If the form values are invalid, display an error.
  • Create a new user via the createUserWithEmailAndPassword method of the Firebase auth module.
  • If the new user creation fails, display an error.
  • Otherwise, our new user is now in a signed-in state.
  • Grab the user auth token (this is called ID token in Firebase lingo and represents a JSON Web Token (JWT))1.
  • Redirect the user to the homepage with the token as a URL parameter, i.e., /?token=${USER_AUTH_TOKEN}.

Before delving into the code for how to do this, I’d like to point out that the project has module aliasing set up to prevent pesky relative imports. e.g.,

// This ...
import { auth } from "../../firebase/init";

// Becomes this ...
import { auth } from "@scripts/firebase/init";

This is achieved by updating the tsconfig.json file to include the alias:

// 📂 tsconfig.json
{
   // ...
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/components/*"],
      "@layouts/*": ["src/layouts/*"],
      "@scripts/*": ["src/scripts/*"],
      "@stores/*": ["src/stores/*"],
      "@constants/*": ["src/constants/*"]
    }
  }
}

We will reference existing modules in the project via the relevant module alias. Now, here is the annotated code for handling the user sign-up:

<!-- 📂 src/pages/signup.astro -->
<script>
   // import the Validator from the tiny "validator.tool" library
   import Validator from "validator.tool";
   import { createUserWithEmailAndPassword } from "firebase/auth";
   // Import the auth module from `src/scripts`
   import { auth } from "@scripts/firebase/init";
   // Import basic form validation rules
   import { authClientValidationRules } from "@scripts/authClientValidationRules";

  // Type alias for the form values
   type FormValues = {
     email?: string;
     password?: string;
   };

   // Grab the submit button element
   const submitButton = document.getElementById(
     "submit-signup-form"
   ) as HTMLButtonElement | null;

   // Grab the form element
   const form = document.getElementById("signup-form") as HTMLFormElement | null;

    // Initialise the validator
   const validator = new Validator({
     form,
     // Pass in basic rules already existing in the project
     rules: authClientValidationRules,
   });


   if (validator.form) {
     // Attach a submit event handler on the form
     validator.form.onsubmit = async (evt) => {
       evt.preventDefault();

       const errors = validator.errorMessages;
       const values = validator.getValues() as FormValues;

       //Check for errors
       if (Object.keys(errors).length > 0) {
         const errorMessages = Object.values(errors).join("...and...");
         return alert(errorMessages);
       }

       const { email, password } = values as Required<FormValues>;

       if (!submitButton) {
         return alert("Missing form button");
       }

       try {
         // Show submitting state
         submitButton.innerText = "Submitting";
         submitButton.disabled = true;

         // Create the new user
         const { user } = await createUserWithEmailAndPassword(
           auth,
           email,
           password
         );

  		// redirect the user to the homepage with their token
         const token = await user.getIdToken();
         window.location.href = `/?token=${token}`;
       } catch (error) {
         submitButton.innerText = "Signup";
         submitButton.disabled = false;

         alert(error);
       }
     };
   }
</script>

In the solution above, we’re handling form validation via validator.js but could have used any other library. Another minimal framework agnostic library that makes a good choice is Felte.

Handling user sign in

With user signup handled, the process for user signup is the same except for one change. Instead of calling the createUserWithEmailAndPassword method, we’ll use the signInWithEmailAndPassword firebase auth method.

Notice how the flow is identical in the code below:

<!-- 📂 src/pages/signin.astro -->
<!-- ... -->

<script>
  import { signInWithEmailAndPassword } from "firebase/auth";
  import Validator from "validator.tool";
  import { auth } from "@scripts/firebase/init";
  import { authClientValidationRules } from "@scripts/authClientValidationRules";

  type FormValues = {
    email?: string;
    password?: string;
  };

  const form = document.getElementById("signin-form") as HTMLFormElement | null;
  const submitButton = document.querySelector(
    "#signin-form button[type='submit']"
  ) as HTMLButtonElement | null;

  const validator = new Validator({
    form,
    rules: authClientValidationRules,
  });

  if (validator.form) {
    validator.form.onsubmit = async (evt) => {
      evt.preventDefault();

      const errors = validator.errorMessages;
      const values = validator.getValues() as FormValues;

      if (Object.keys(errors).length > 0) {
        const errorMessages = Object.values(errors).join("...and...");
        return alert(errorMessages);
      }

      const { email, password } = values as Required<FormValues>;

      if (!submitButton) {
        return alert("Missing form button");
      }

      try {
        submitButton.innerText = "Submitting";
        submitButton.disabled = true;

        const { user } = await signInWithEmailAndPassword(
          auth,
          email,
          password
        );

        const token = await user.getIdToken();
        window.location.href = `/?token=${token}`;
      } catch (error) {
        submitButton.innerText = "Signin";
        submitButton.disabled = false;

        alert(error);
      }
    };
  }
</script>

With these in place, we’ve got authentication handled!

However, a question that may remain in your heart is, why exactly are we sending the user token in the homepage redirect URL?

Implementing protected pages

Every page in our application is statically generated except for index.astro I.e., the homepage.

The homepage is server-side rendered because we want to ensure it’s protected, i.e., only authenticated users ever land here.

We will discuss how we’ll achieve this, but first, we need to write some code that runs on the server here.

Initialising Firebase on the server

During the project initialisation, we downloaded a private key for server access. This is a JSON file in the form:

{
  type: "...",
  project_id: "..."
   // more properties
}

We need these values to initialise our server application. So, create a .env file to store these secrets. Then, we’ll break up the JSON keys into individual environment variables as shown below:

FIREBASE_PRIVATE_KEY_ID = "...";
FIREBASE_PRIVATE_KEY = "...";
FIREBASE_PROJECT_ID = "...";
FIREBASE_CLIENT_EMAIL = "...";
FIREBASE_CLIENT_ID = "...";
FIREBASE_AUTH_URI = "...";
FIREBASE_TOKEN_URI = "...";
FIREBASE_AUTH_PROVIDER_CERT_URL = "...";
FIREBASE_CLIENT_CERT_URL = "...";

Save the env file. Without this, we won’t be able to access the application resources from our server.

✨ Fun fact: As discussed in Chapter 5, we’re providing Typescript support for these environment values in .env.d.ts.

Protecting the home page route

Once a user has successfully signed in, Firebase generates a unique ID token that serves as their unique identifier and provides access to various resources, such as Firebase Cloud Storage.

I have loosely referred to this as auth tokens. We will use this ID token to recognise the user on our server.

✨ Fun fact: Firebase ID tokens are short-lived and last for an hour.

Consider the flow below:

The protected route flow

The protected route flow.


  • The flow kicks off with the user landing on the homepage.

    Note that the following steps are performed on the server, i.e., within the frontmatter section of our server-side rendered page.

  • Then, retrieve the user ID token from the URL (first-time user) or the request cookies (returning user).
  • Verify the validity of the token. We will use the Firebase server SDK (Firebase admin) to check this.
  • If the token is invalid or doesn’t exist, the user is unauthorised. Redirect them to the /signin page.
  • If the token is valid, set the token as a cookie.

✨Fun fact: by setting the token via cookies, we can remove the token from the URL and refresh without losing the user signed-in state. Every request will send back the cookie to the server, where we can recheck its validity.

Now, here’s the implementation:

// 📂 src/pages/index.astro

// ...
import { serverApp } from "@scripts/firebase/initServer";
import { getAuth } from "firebase-admin/auth";
import { TOKEN } from "@constants/cookies";

// Get client token from the URL param
const url = new URL(Astro.request.url);
const urlTokenParam = url.searchParams.get("token");

// Get token from cookies
const cookieToken = Astro.cookies.get(TOKEN);
const token = urlTokenParam || cookieToken.value;

if (!token) {
  // Unauthorised user. Redirect to sign in
  return Astro.redirect("/signin");
}

const auth = getAuth(serverApp);

try {
  // verify the auth token
  await auth.verifyIdToken(token);

  // set token cookie
  // Note that the "TOKEN" constant refers to the string "X-Token."
  Astro.cookies.set(TOKEN, token, {
    path: "/",
    httpOnly: true,
    secure: true,
  });
} catch (error) {
  console.error("Could not decode token", {
    fromCookie: !!cookieToken.value,
    fromUrl: !!urlTokenParam,
  });

  // Error occurred, e.g., invalid token. Redirect to sign in
  return Astro.redirect("/signin");
}

The token cookie set in the browser response

The token cookie set in the browser response.


Updating the redirect URL

When a user successfully signs in, the user looks something like localhost:3000/?token=${some-long-string}.

After performing our token validation on the server and returning the protected HTML page, we may update the URL to remove the token parameter.

// Before
localhost:3000/?token=${some-long-string}

// After
localhost:3000

This is not necessary, but a nice UX touch.

Since we want to do this on the client, our go-to solution is to add a client <script> to the page!

Consider the solution below:

<!-- 📂 src/pages/index.astro -->
<!-- ... -->

<script>
  // Enhancement: remove the token from the URL after the page's parsed.
  const url = new URL(window.location.href);
  const urlTokenParam = url.searchParams.get("token");

  if (urlTokenParam) {
    // delete the token param from the URL
    url.searchParams.delete("token");

    // update history without a refresh with the new URL
    window.history.pushState({}, "", url.href);
  }
</script>

The solution is arguably easy to reason about, with the crux after getting the search parameter being window.history.pushState(...). 2

Log out a user from the protected page

The top left section of the application’s navigation bar includes a sign-out button. When a user clicks this, we will sign them out of the application.

To sign out a user, we will use the Firebase client SDK to log a user out of the device.

However, remember that the protected index page checks the token request cookie value.

When we sign out a user using the Firebase client SDK, the issued client token remains valid for up to an hour (depending on when it was issued).

So, consider the flow for our solution below:

The user sign out flow.

The user sign out flow..


Let’s start our implementation by updating the client application to handle the click event on the sign-out button and initiate our flow as shown below:

<!-- 📂 src/pages/layouts/BaseLayout.astro -->
<!-- ... -->
<script>
  import { auth } from "@scripts/firebase/init";

   // Grab the sign-out button
  const signoutButton = document.getElementById("sign-out-button") as
    | HTMLButtonElement
    | undefined;

  if (signoutButton) {
    // Add a click event listener on the button
    signoutButton.addEventListener("click", async () => {
      try {
        // Disable the button while we log the user out
        signoutButton.disabled = true;
        // Change button text to read "Signing out ..."
        signoutButton.innerText = "Signing out ...";
        // Invalidate server http cookie
        const response = await fetch("/api/auth/signout", {
          method: "POST",
        });

        if (!response.ok) {
          throw new Error("server signout failed");
        }
  /**
  	* sign the user out via the signOut method
  * on the Firebase auth module
  */
        await auth.signOut ();
  // Redirect to the signin page
        window.location.href = "/signin";
      } catch (error) {
        signoutButton.disabled = false;
        alert(error);
      }
    });
  }
</script>

We’re making a request to /api/auth/signout, but the API route does not exist.

Let’s change that with the following code:

// 📂 src/pages/api/auth/signout.ts
// ...

import { TOKEN } from "@constants/cookies";

export const post: APIRoute = (ctx) => {
  ctx.cookies.delete(TOKEN, {
    path: "/",
  });

  return {
    body: JSON.stringify({ message: "successfully signed out" }),
  };
};

After successful sign-out, attempt to visit the protected page localhost:3000, and you’ll be automatically redirected to /sign.

We’re now cooking with gas! 🔥

Cloud storage setup

We’ve got a big part of our application functioning — largely the authentication and keeping the index page protected. However, we’re protecting an empty page at the moment. So users cannot record or view other users’ recordings.

Let’s fix this by setting up cloud storage to save user recordings on the server.

Go to the Firebase console and click “See all build features” to find the cloud storage service.

Viewing all build features on the Firebase console

Viewing all build features on the Firebase console.


Next, select the Storage service.

Selecting the storage service

Selecting the storage service.


Then begin the setup.

Clicking get started on the Storage service page

Clicking get started on the Storage service page.


Keep the storage rules as-is:

The default storage rule

The default storage rule.


Then select a server location.

BeAudible is a hypothetical US startup, so I’ll choose a US location here.

Selecting a Storage location

Selecting a Storage location.


Once the setup is complete, visit the Storage page and copy the bucket name in the form gs://{name-of-project}.appspot.com.

The Storage bucket name

The Storage bucket name.


Excellent!

When we upload and get the user recordings, we’ll need this to connect to the storage servers.

Uploading audio recordings

The recorder user interface is powered by a React Recorder component hydrated via the client:load directive.

<Recorder client:load>...</Recorder>

Open the Recorder component and consider the onAudioDownload callback.

// src/components/AudioRecorder.tsx
// ...
<VoiceRecorder
  onAudioDownload={(blob: Blob) => {
    // 👀 upload recording
  }}
/>

After a user completes the recording, this callback will be invoked. Our first task is to go ahead and upload the audio blob to the server.

Sending audio blob to a server endpoint

Sending audio blob to a server endpoint.


Handling uploads via an API route

Let’s go ahead and create the API endpoint that’ll receive the audio blob from the client.

Consider the flow for our solution below:

The save recording endpoint flow diagram

The save recording endpoint flow diagram.


Now, here’s the annotated code:

// 📂 src/pages/api/recording.ts
// ...
import type { APIRoute } from "astro";

// nanoid will be used to generate unique IDs
import { nanoid } from "nanoid";
import { TOKEN } from "@constants/cookies";
import { getAuth } from "firebase-admin/auth";
import { BUCKET_NAME } from "@constants/firebase";
import { getStorage } from "firebase-admin/storage";
import { serverApp } from "@scripts/firebase/initServer";

// get firebase server auth module
const auth = getAuth(serverApp);

export const post: APIRoute = async (ctx) => {
  // Create an error response
  const authUserError = new Response("Unauthenticated user", { status: 401 });

  try {
    // Get token cookie
    const authToken = ctx.cookies.get(TOKEN).value;

    // not present, return error response
    if (!authToken) {
      return authUserError;
    }

    // verify the user token
    await auth.verifyIdToken(authToken);
  } catch (error) {
    /**
     * Return error response, e.g.,
     * if the token verification fails
     */
    return authUserError;
  }

  try {
    // Get the audio blob from the client request
    const blob = await ctx.request.blob();

    // Get access to the firebase storage
    const storage = getStorage(serverApp);
    const bucket = storage.bucket(BUCKET_NAME);

    // convert Blob to native Node Buffer for server storage
    const buffer = Buffer.from(await blob.arrayBuffer());
    const file = bucket.file(`recording-${nanoid()}.wav`);

    // save to firebase storage
    await file.save(buffer);

    // return a successful response
    return {
      body: JSON.stringify({
        message: "Recording uploaded",
      }),
    };
  } catch (error) {
    console.error(error);
    return new Response("Something went horribly wrong", { status: 500 });
  }
};
// ...

Uploading recordings from the client

Now that we’ve got the API endpoint ready to receive client requests let’s go ahead and upload the user recordings from the client.

Instead of clogging our user interface components with the upload logic, I find it more maintainable to move such business logic away from the UI components and, in our case, have this collocated with the application state managed via nanastores.

Remember nanostores?

We’ll use nanostores for state management. The ~1kb library is simple and efficient for our use case.

Create a new audioRecording.ts file to handle our recording state and also be responsible for exposing a uploadRecording method.

Consider the implementation below:

// 📂 src/stores/audioRecording.ts
import { atom } from "nanostores";

/**
 * Deterministic state representation
 */
type Store =
  | {
      blob: null,
      status: "idle",
    }
  | {
      blob: Blob,
      status: "uploading" | "completed" | "failed",
    };

/**
 * Optional naming convention: $[name_of_store]
 * instead of [name_of_store]Store
 *, i.e., $audioRecording instead of audioRecordingStore
 */
export const $audioRecording =
  atom <
  Store >
  {
    // Initialise the atom with the default state
    blob: null,
    status: "idle",
  };

/**
 * upload audio recording action
 */
export const uploadRecording = async (blob: Blob) => {
  // Update $audioRecording state to "uploading."
  $audioRecording.set({
    status: "uploading",
    blob,
  });

  try {
    // POST request to our recording endpoint
    const response = await fetch("/api/recording", {
      method: "POST",
      body: blob, // pass blob as the request body
    });

    if (response.ok) {
      // Successful? Update state to "completed."
      $audioRecording.set({
        status: "completed",
        blob,
      });
    } else {
      // Request failed. Update state to "failed."
      $audioRecording.set({
        status: "failed",
        blob,
      });
    }
  } catch (error) {
    $audioRecording.set({
      status: "failed",
      blob,
    });
  } finally {
    // after 't' revert state to idle again
    const timeout = 3000;
    setTimeout(() => {
      $audioRecording.set({
        status: "idle",
        blob: null,
      });
    }, timeout);
  }
};

Our UI state is well-represented, and the upload action is defined. However, this will only take effect when used in the UI component.

Reacting to UI changes in framework components

We will now update the AudioRecorder UI component to react to the state in the $audioRecording store as shown below:

// 📂 src/components/AudioRecorder.tsx

/**
 * The useStore hook will help with the React
 * component rerenders. In simple terms, it'll hook into the
 * store and react upon any change.
 */
import { useStore } from "@nanostores/react";
import { VoiceRecorder } from "react-voice-recorder-player";
// Import the store and the upload recording action
import { $audioRecording, uploadRecording } from "@stores/audioRecording";

type Props = {
  cta?: string,
};

export const Recorder = (props: Props) => {
  // Get the current application state from the store
  const state = useStore($audioRecording);

  // React deterministically based on the status of the store
  switch (state.status) {
    case "idle":
      return (
        <div>
          <VoiceRecorder
            // 👀 Invoke uploadRecording after a user completes the recording
            onAudioDownload={(blob: Blob) => uploadRecording(blob)}
          />

          {props.cta}
        </div>
      );
    /** 
 Show relevant UI during the uploading state. 
**/
    case "uploading":
      return (
        <div className="flex items-center justify-center w-56 h-56 border border-gray-200 rounded-lg bg-gray-50 dark:bg-gray-800 dark:border-gray-700">
          <div className="px-3 py-1 text-xs font-medium leading-none text-center text-blue-800 bg-blue-200 rounded-full animate-pulse dark:bg-blue-900 dark:text-blue-200">
            Uploading ...
          </div>
        </div>
      );
    /** 
 Show relevant UI during the failed state. 
**/
    case "failed":
      return (
        <div className="bg-red-400 rounded-md py-6 px-3 text-slate-100 motion-safe:animate-bounce">
          An error occurred uploading your recording
        </div>
      );
    /** 
 Show relevant UI during the completed state. 
**/
    case "completed":
      return (
        <div className="bg-green-400 rounded-md py-6 px-3 text-slate-100 motion-safe:animate-bounce">
          Successfully published your recording!
        </div>
      );
    /** 
 Typescript exhaustive checking
 @see https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking
**/

    default:
      const _exhaustiveCheck: never = state;
      return _exhaustiveCheck;
  }
};

Now, a user should be able to record in the browser, and we will go ahead and save the recording on our backend!

Viewing saved recordings in the Firebase emulator

Viewing saved recordings in the Firebase emulator.


Fetching data from the server

We’re rightly saving user recordings, but at the moment, they can’t be viewed on the homepage.

Let’s resolve that.

Our solution is to fetch the recordings on the server and send the rendered HTML page to the client.

Here’s the code solution:

// 📂 src/pages/index.astro


import { BUCKET_NAME } from "@constants/firebase";
import LinkCTA from "@components/LinkCTA.astro";
import AudioPlayer from "@components/AudioPlayer.astro";
// ...

// Represent the recordings with the "Audible" type alias
type Audible = { url: string; timeCreated: string };

// audibles will hold the list of "Audibles."
let audibles: Audible[] = [];
const storage = getStorage(serverApp);


try {
   /**
	 *  After verifying the user auth token
  	 * and setting the token cookie ...
	*/
    try {
    // get all recordings in the storage bucket
    const bucket = storage.bucket(BUCKET_NAME);
    const [files] = await bucket.getFiles();

    audibles = await Promise.all(
      files.map(async (file) => {
        const [metadata] = await file.getMetadata();

        // return the url and timeCreated metadata
        return {
          url: file.publicUrl(),
          timeCreated: metadata.timeCreated,
        };
      })
    );
  } catch (error) {
    console.error(error);
    console.error("Error fetching audibles");
    return Astro.redirect("/signin");
  }
}

//...

Now update the component template section to render the “audibles”. We’ll leverage the AudioPlayer component, passing it the audible url and the timeCreated metadata.

<div class="flex flex-col items-center">
    {
      audibles.length === 0 ? (
        <>
          <Empty />
          <LinkCTA href="/record">Record</LinkCTA>
        </>
      ) : (
        audibles
          .sort((a, b) =>
            new Date(a.timeCreated) < new Date(b.timeCreated) ? 1 : -1
          )
          .map((audible) => (
            <AudioPlayer url={audible.url} timeCreated={audible.timeCreated} />
          ))
      )
    }
</div>

In the code above, we display an Empty user interface empty if there are no audibles. Otherwise, we render a sorted list of audibles.

Rendering the sorted list of audio recordings

Rendering the sorted list of audio recordings.


Submitting HTML forms

The final puzzle in our application is handling the submission of the feedback form.

I’ve included this feature to show an example of handling a form within the same server-side rendered page, i.e., without creating an API endpoint to handle the form request.

Take a look at the form element and notice how its method attribute is set to POST:

// 📂 src/layouts/BaseLayout.astro
// ...
<form class="mx-auto flex" method="POST">
  ...
</form>

By default, the browser will send a POST request to the server when this form is submitted, which we can capture and react upon.

In the frontmatter section of the index.astro page, we can add a condition to handle the feedback form requests as shown below:

// ...
if (Astro.request.method === "POST") {
  try {
    // Get the form data
    const data = await Astro.request.formData();
    /**
     * Get the feedback value.
     * Corresponds to the form input element value of the name, "feedback".
     */
    const feedback = data.get("feedback");

    // Do something with the data
    console.log({ feedback });

    // Do something with the data
  } catch (error) {
    if (error instanceof Error) {
      console.error(error.message);
    }
  }
}
// ...

I’m keeping this simple by just logging the feedback on the server. However, we could save this value to a database in the real world. The crux here is receiving the form values, as shown above.

The logged feedback data

The logged feedback data.


Conclusion

Astro is great for building content-focused websites such as blogs, landing pages etc. However, we can do much more with Astro!

Suppose you can build the application as a multi-page application (MPA), i.e., not a single-page application, and can leverage islands of interactivity (component islands). In that case, you can build it with Astro.

Footnotes

  1. What is a JWT? https://jwt.io/introduction

  2. The pushState method: https://developer.mozilla.org/en-US/docs/Web/API/History/pushState