June 07, 2023

Integrating Spin with Static Site Generators

David Flanagan David Flanagan

frontend astro static-site-generators

Integrating Spin with Static Site Generators

Building your backend with Spin is a fantastic way to provide your developers with a strong developer experience that doesn’t need virtual machines or containers to provide consistency, but how do we integrate this with our frontend?

Serverless backends are great, but we don’t expect our users to bust out curl to use them, so let’s see how to integrate a static website with our Spin backend.

Static websites are a fantastic approach to providing a frontend to serverless functions, because they’re fast, cheap, and easy to deploy.

They’re fast because they’re just HTML, CSS, and JavaScript; typically rendered in advance and served from a simple HTTP server; meaning once they’ve delivered to your clients, they’re ready for the DOM to be visualized: no database or client side requests required.

Developers have been adopting static site generators, such as Gatsby, Hugo, and Jekyll, for years now because they provide the best Lighthouse score you can get.

In today’s article, we’ll take a look at using such a framework to provide an interface to your Spin functions.

Our Spin Backend

Our Spin backend provides a function that allows us to interface with OpenAI’s ChatGPT. Spin as a backend is GREAT for working with external APIs, because making outbound HTTP requests is a piece of cake with the fetch API (JS/TS), or the SDK helpers for Rust and others.

Here’s our code for the backend:

const encoder = new TextEncoder("utf-8");

export async function handleRequest(request) {
  const apiKey = spinSdk.config.get("openai_token");

  const queryString = await request.text();
  const formData = new URLSearchParams(queryString);

  const response = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model: "gpt-3.5-turbo",
      messages: [
        {
          role: "user",
          content: `What language is this? '${formData.get("text")}'`,
        },
      ],
      temperature: 0.7,
    }),
  });

  const data = await response.json();

  return {
    status: 200,
    body: encoder.encode(data.choices[0].message.content).buffer,
  };
}

Of course, we can consume this API with hurl or curl, but we want to provide a frontend for our users.

Building Our Static Website

For today’s example, we’re going to use Astro to build our static website. Astro is pretty new, but also pretty awesome. It’s a framework for building static websites that doesn’t restrict you to a single UI framework, such as React. So you can use React, Svelte, Vue, WebComponents, or any combination of them.

To create a new Astro website, you can run the following command:

npm create astro@latest

We’re not going to focus on the Astro specifics today, other than the components we require and the config for static site generation; so if you wish to learn more you can do so on their getting started guide.

Configuring for SSG

Astro, by default, is already configured this way. However, some templates do come with a slightly different configuration. So, to ensure we’re all on the same page, let’s take a look at our astro.config.mjs file:

export default defineConfig({
  output: "static",
});

The output should be "static", OR the output property should be omitted from defineConfig. If you see output set to any other value, it’s likely your template has been configured for server-side rendering, rather than static site generation.

Our Frontend

Astro uses it’s pages directory to store the pages of our website. So, let’s edit the index.astro file in the pages directory.

We’re just going to augment the default homepage with our integration to get the demo working. As such we need to add a form to submit requests to our backend endpoint.

<form action="/api" method="post">
  <input type="text" name="text" placeholder="Enter Language Text" />
  <input type="submit" value="Determine Language" />
</form>

We can use relative paths for the form submission because we’re deploying EVERYTHING together, either with spin up locally or with spin deploy to Fermyon Cloud.

Our form is as basic as it gets, we’re just asking the user to provide some text and we’ll use our backend to determine the language.

While I’m using a simple form, your frontend team can actually use their knowledge and experience to build this on React, Svelte, and so forth. They don’t need to throw away their knowledge to integrate Spin.

Here’s the full version:

---
import BaseHead from "../components/BaseHead.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import { SITE_TITLE, SITE_DESCRIPTION } from "../consts";
---

<html lang="en">
  <head>
    <BaseHead title="{SITE_TITLE}" description="{SITE_DESCRIPTION}" />
  </head>
  <body>
    <header title="{SITE_TITLE}" />
    <main>
      <h1>🧑‍🚀 Hello, Spin Fans!!</h1>

      <form action="/api" method="post">
        <input type="text" name="text" placeholder="Enter Language Text" />
        <input type="submit" value="Determine Language" />
      </form>

      <p>
        Welcome to the official <a href="https://astro.build/">Astro</a> blog
        starter template. This template serves as a lightweight,
        minimally-styled starting point for anyone looking to build a personal
        website, blog, or portfolio with Astro.
      </p>
      <p class="rawkode">
        This template comes with a few integrations already configured in your
        <code>astro.config.mjs</code> file. You can customize your setup with
        <a href="https://astro.build/integrations">Astro Integrations</a> to add
        tools like Tailwind, React, or Vue to your project.
      </p>
      <p>Here are a few ideas on how to get started with the template:</p>
      <ul>
        <li>Edit this page in <code>src/pages/index.astro</code></li>
        <li>
          Edit the site header items in <code>src/components/Header.astro</code>
        </li>
        <li>
          Add your name to the footer in
          <code>src/components/Footer.astro</code>
        </li>
        <li>
          Check out the included blog posts in <code>src/pages/blog/</code>
        </li>
        <li>
          Customize the blog post page layout in
          <code>src/layouts/BlogPost.astro</code>
        </li>
      </ul>
      <p>
        Have fun! If you get stuck, remember to
        <a href="https://docs.astro.build/">read the docs </a> or
        <a href="https://astro.build/chat">join us on Discord</a> to ask
        questions.
      </p>
      <p>
        Looking for a blog template with a bit more personality? Check out
        <a href="https://github.com/Charca/astro-blog-template"
          >astro-blog-template
        </a>
        by <a href="https://twitter.com/Charca">Maxi Ferreira</a>.
      </p>
    </main>
    <footer />
  </body>
</html>

Testing Our Integration

Now that Astro has it’s index page configured with our form, we can spin everything up and test it out.

In-order to use Spin, we want a directory structure that allows our spin.toml to be configured to point to each of our projects.

For today’s demo, we have the following directory structure:

.
├── frontend
│  ├── dist
│  ├── public
│  └── src
├── src
└── target

We have our frontend in the frontend directory, and our backend in the src directory. This allows us to configure our spin.toml file as follows:

spin_manifest_version = "1"
authors = ["David Flanagan <david@rawkode.dev>"]
description = ""
name = "real-app-openai-language-guesser"
trigger = { type = "http", base = "/" }
version = "0.1.0"

[[component]]
id = "real-app-openai-language-guesser"
source = "target/spin-http-js.wasm"
exclude_files = ["**/node_modules"]
allowed_http_hosts = ["https://api.openai.com"]

[component.config]
openai_token = "{{ openai_token }}"

[component.trigger]
route = "/api"

[component.build]
command = "npm run build"

[variables]
openai_token = { required = true }

[[component]]
source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.0.2/spin_static_fs.wasm", digest = "sha256:65456bf4e84cf81b62075e761b2b0afaffaef2d0aeda521b245150f76b96421b" }
id = "frontend"
files = [{ source = "frontend/dist", destination = "/" }]
environment = { FALLBACK_PATH = "index.html" }

[component.trigger]
route = "/..."

[component.build]
command = "npm run build"

Things that are important here. First, our backend is bound to the /api path:

[component.trigger]
route = "/api"

We’re also allowing it to speak to a remote host, for sending requests to OpenAI:

allowed_http_hosts = ["https://api.openai.com"]

Finally, our backend is configured to use the openai_token variable:

[component.config]
openai_token = "{{ openai_token }}"

which we provide with an environment variable:

export SPIN_CONFIG_OPENAI_TOKEN="..."

Next, we can use the Spin file-server to host our static website:

[[component]]
source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.0.2/spin_static_fs.wasm", digest = "sha256:65456bf4e84cf81b62075e761b2b0afaffaef2d0aeda521b245150f76b96421b" }
id = "frontend"
files = [{ source = "frontend/dist", destination = "/" }]

By default, the Spin file-server uses index.html as a fallback for missing files and as a directory index: so it’ll do the right thing for most frontend frameworks.

We’re also specifically pointing to the frontend/dist directory for the static assets, which is also used by most frontend frameworks for the output.

For this to work well, we configure the file-server component to match any paths not /api with a wildcard:

[component.trigger]
route = "/..."

Finally, we don’t want to force our developers to run the build command for their website manually, so we hook this up to Spin:

[component.build]
command = "npm run build"

Now, we can run spin build and everything will be built, ready to be run:

spin build
spin up

Of course, if you could also run this with a single command:

spin build --up

Watching for Changes

This doesn’t need to end here! As developers iterate on their changes, we want them to get real-time updates in their browser. To do so, we can hook into spin watch to build and relaunch their changes as soon as they happen.

To do so, we need to configure which files to watch for the frontend component:

[component.build]
watch = ["src/**/*", "public/**/*"]

Simple, right? Now just run spin watch.

Seeing it in Action

Want to see it working? Check it out.

Conclusion

Spin is a fantastic way to provide a backend for your frontend developers. It’s easy to integrate with your existing tooling, and it’s easy to integrate with your existing workflows.

You don’t need to throw away your knowledge and experience with frontend frameworks to hook into the power and performance of the Spin and WebAssembly for your backend.

To learn more about Spin, check out the documentation, or join the community on Discord.

Until next time 🤘🏻

 

 

 


🔥 Recommended Posts


Quickstart Your Serveless Apps with Spin

Get Started