Implementing the "Cloudflare worker stack"
György Márk Varga
Posted on August 15, 2024
So you need a full serverless infrastructure that can scale to zero and infinity all for $0 (with lower traffic)? All this with Cloudflare services. I've got you covered. (and this isn't even an ad, Cloudflare is just thaaat good)
Let's explore how you can use Cloudflare Workers to build your next app. You can even use this as a template when starting your project.
The specific application that we will build is a system that uses AI to generate a poem and an image with hourly timing, based on the current Budapest weather. Then saves it to the database and R2 storage. We will keep a record of how long a generation took in Cloudflare's KV store. We will display the current status on a Cloudflare Pages page a Nextjs web application.
Now that we know the purpose and operation of the application. Let's take a look at what this architecture looks like with the help of a diagram.
Our Tech stack will look like this:
- Cloudflare Workers
- Cloudflare D1 database
- Cloudflare R2 object storage
- Cloudflare KV (Key value storage)
- Cloudflare scheduler (cron jobs)
- Cloudflare AI
- Cloudflare Pages (Next.js)
- Hono web framework
- Drizzle ORM
Let's move on to the implementation:
First of all, we will need a Cloudflare account. After logging in, we can start working.
However, we will first start creating the project on our own machine. We issue the following command in the terminal based on the Cloudflare docs:
npm create cloudflare@latest cloudflare-poem -- --framework=hono
If the packages are not installed, this command does the job for us. In fact, it will even deploy our project.
Open the created project in your favorite IDE. In index.ts we can also see the simple structure of the code:
import { Hono } from 'hono'
type Bindings = {
[key in keyof CloudflareBindings]: CloudflareBindings[key]
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
export default app
Then create a D1 database and associate it with our worker. Based on the official tutorial, it will not be difficult for us to do this.
Let's issue this command standing in the root of our project:
npx wrangler d1 create cloudflare-poem-db
This creates our database. This command returns the values in the terminal that we need to copy into the wrangler.toml
file. This is what they look like to me:
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "cloudflare-poem-db"
database_id = "ebf949ce-56e8-4d98-ad63-a7ba7aa8df64"
migrations_dir = "drizzle/migrations"
With this, we practically connected the system to our DB. However, the next step is to set up the database schema. Here we can already see that we have a migrations_dir
key, we will talk about this a little later. This is where Drizzle ORM will come to our aid. We also install it:
npm i drizzle-orm
npm i -D drizzle-kit
Next, create a drizzle.config.json
file in the root directory that tells Drizzle where to create the migration files and where to find the schema file:
{
"out": "drizzle/migrations",
"schema": "src/db/schema.ts"
}
We can also create the schema.ts
file with this momentum in the src/db
folder:
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const poems = sqliteTable("poems", {
id: integer("id", { mode: "number" }).primaryKey(),
text: text("text").notNull(),
imgUrl: text("imgUrl").notNull(),
createdDate: integer("created_date", { mode: "timestamp" }).notNull().default(sql`(current_timestamp)`),
});
We store the text of the poem, the URL of the associated generated image and the date of the creation. That will be enough for us.
Our next task will be to push this schema to our D1 database running in Cloudflare. For this, let's create a couple of commands in the package.json
file, which we can use to start this.
This can also go into the scripts block:
"db:generate": "drizzle-kit generate",
"db:migrate": "wrangler d1 migrations apply cloudflare-poem-db --remote"
We can use generate
to generate our schema. Let's start that. Then we need to migrate these to our database running in Cloudflare, so the next command we run will be migrate
. If everything is fine, we can see a green checkmark that our .sql
migration file has been applied successfully.
We can also see this on the Cloudflare dashboard page, under the "Workers & Pages" menu item, under "D1". If everything went fine, we can see that our database schema has been applied correctly.
Now it's time to connect the scheduler. First, let's say that a new record is created in the database every 1 minute. For this, we need a scheduled event export in the index.ts
file and, of course, settings must also be made in wranger.toml
.
Add the following to wrangler.toml:
[triggers]
crons = [ "* * * * *" ]
This sets our trigger to once every minute for now. It will be perfect for us to test. Well, let's also look at the index.ts
file:
import { Hono } from 'hono';
import { drizzle } from 'drizzle-orm/d1';
import { poems } from "./db/schema";
type Bindings = {
[key in keyof CloudflareBindings]: CloudflareBindings[key]
}
const app = new Hono<{ Bindings: Bindings }>();
app.get('/', (c) => {
return c.text('Hello Hono!');
});
export default {
scheduled(
event: ScheduledEvent,
env: Bindings,
ctx: ExecutionContext
) {
const delayedProcessing = async () => {
await createPoem(env);
};
ctx.waitUntil(delayedProcessing());
},
fetch(request: Request, env: Bindings, ctx: ExecutionContext) {
return app.fetch(request, env, ctx);
},
};
async function createPoem(env: Bindings) {
const db = drizzle(env.DB);
const poemData = {
text: 'The rose is red',
imgUrl: 'https://example.com/rose.jpg'
};
const result = await db.insert(poems).values(poemData).returning();
}
Here we see exactly what happens is that we create a createPoem
function that runs every minute and uses the Drizzle ORM to save a fairly simple poem to the database.
Now let's integrate an AI model provided by Cloudflare and generate a poem with it.
To do this, first add a binding in wrangler.toml
:
[ai]
binding = "AI"
Then run the cf-typegen
scripts from package.json
, because this will ensure that we have the right env variables and that typescript will not have errors.
We modify our createPoem
function as follows:
async function createPoem(env: Bindings) {
const db = drizzle(env.DB);
const aiGeneratedPoemResponse = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
prompt: "Write a poem about a rose."
});
const poemData = {
text: aiGeneratedPoemResponse.response,
imgUrl: 'https://example.com/rose.jpg'
};
const result = await db.insert(poems).values(poemData).returning();
}
You can see that we are using the latest model of llama here. This works very well, it also generates the poem for us. This is enough for us to start, but let's go further. What else do we want?
Of course, we would like the poem to be constructed from the current weather, an image generated by AI should also be prepared and saved in the database (poem text) and in the R2 object storage (picture). It would also not hurt to store in the KV store how long the generation lasted.
wrangler r2 bucket create cloudflare-poem-bucket
And then this can go into our wrangler.toml
config file:
[[r2_buckets]]
binding = 'BUCKET'
bucket_name = 'cloudflare-poem-bucket'
Then let's create a kv namespace for it. Now, for the sake of variety, let's create this on Cloudflare's dashboard (because it is possible there as well). Under Workers & Pages, in the KV menu, go to Create Namespace, then name it and get its id, which we need.
This can also be pushed into wrangler.toml
(I named my KV namespace “KV” for noble simplicity)
[[kv_namespaces]]
binding = "KV"
id = "f1c144608d9c4a1991c98a966e800934"
And with that, we would be done with configuring the tools of our backend system. Let's see what else is needed to create the above-mentioned system. Let's go to index.ts
and complete our code with the following. It will be quite a bit, but after the code I will explain what we did there:
import { Hono } from 'hono';
import { drizzle } from 'drizzle-orm/d1';
import { poems } from "./db/schema";
import { desc } from "drizzle-orm";
type Bindings = {
[key in keyof CloudflareBindings]: CloudflareBindings[key]
}
interface WeatherResponse {
weather: { description: string }[];
main: { temp: number; feels_like: number };
}
const app = new Hono<{ Bindings: Bindings }>();
app.get('/current-weather-poem', async (c) => {
const db = drizzle(c.env.DB);
const poem = await db.select().from(poems).orderBy(desc(poems.createdDate)).limit(1);
return c.json(poem[0]);
});
export default {
scheduled(
event: ScheduledEvent,
env: Bindings,
ctx: ExecutionContext
) {
const delayedProcessing = async () => {
await createPoem(env);
};
ctx.waitUntil(delayedProcessing());
},
fetch(request: Request, env: Bindings, ctx: ExecutionContext) {
return app.fetch(request, env, ctx);
},
};
async function createPoem(env: Bindings) {
const db = drizzle(env.DB);
const startTime = Date.now();
const currentWeatherInBudapestResponse = await fetch(`https://api.openweathermap.org/data/2.5/weather?lat=47.497913&lon=19.040236&appid=${env.OPENWEATHER_API_KEY}`);
const currentWeatherInBudapest = await currentWeatherInBudapestResponse.json() as WeatherResponse;
const aiGeneratedPoemResponse = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
prompt: `Write a poem about the current weather in Budapest. Here is the weather in an object JSON: ${JSON.stringify(currentWeatherInBudapest)}. Please make sure to not make anything up, just write about the exact weather that is provided. Please note that the temperature is in Kelvin (main.temp, main.feels_like), so convert it to Celsius! Please only write the text of the poem, nothing else!`
}) as { response: string }
const imageName = generateRandomImageName();
const aiGeneratedImageResponse = await env.AI.run(
"@cf/stabilityai/stable-diffusion-xl-base-1.0",
{
prompt: `${currentWeatherInBudapest.weather[0].description} in Budapest, Hungary.`
}
);
await env.BUCKET.put(imageName, aiGeneratedImageResponse);
const poemData = {
text: aiGeneratedPoemResponse.response,
imgBucketName: imageName,
createdDate: new Date().toISOString()
};
const [insertedPoem] = await db.insert(poems).values(poemData).returning({
id: poems.id,
});
const endTime = Date.now();
const elapsedTime = endTime - startTime;
await env.KV.put(insertedPoem.id.toString(), elapsedTime.toString());
}
function generateRandomImageName(): string {
return `image_${Math.random().toString(36).substring(2, 15)}`;
}
First, let's look at the createPoem
function. In the first round, here we query the Budapest weather from OpenWeatherMap. Where you have to register to get an API key. We can do that HERE. Also, by definition, we need to include this in the wrangler.toml
file:
[vars]
OPENWEATHER_API_KEY = <YOUR_API_KEY>
We also have a scheduled function that will run with the frequency set in the wrangler.toml
file and this will trigger the createPoem
function.
We will need the "/current-weather-poem"
endpoint to be able to return the newly created poem and all its data.
How simple is it?
Now let's look at Cloudflare Pages, where we can solve to display both the poem and the image inside a Next.js application.
We will have a similarly simple task. Within Cloudflare, we can also create a Next.js project, where we can create a full stack app. We can start this with a command:
npm create cloudflare@latest cloudflare-poem-next -- --framework=next
If we go through the setup with the default settings, we will get a deployed Nextjs application.
From this application, we can access data from our backend system in many ways.
- Since this is a full stack application, we can directly access the database, KV store and R2 object storage with the help of React Server Components.
- We can also call the endpoint that we defined in our backend worker.
We look at both ways as an example. We will query the generation time and the image directly from KV and R2, while the poem and related data from the REST API endpoint. There is also a wrangler.toml
file in the Nextjs project, where we can bind these systems together with the application. The corresponding values for KV and R2 must be entered here. Mine looks like this. Insert the appropriate ID for the KV:
name = "cloudflare-poem-next"
compatibility_date = "2024-08-06"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
[[kv_namespaces]]
binding = "KV"
id = "f1c144608d9c4a1991c98a966e800934"
[[r2_buckets]]
binding = 'BUCKET'
bucket_name = 'cloudflare-poem-bucket'
Next, remove all the frills from globals.css, leaving only this:
@tailwind base;
@tailwind components;
@tailwind utilities;
Then go into the page.tsx
file and create the code like this:
import { getRequestContext } from "@cloudflare/next-on-pages";
import { marked } from 'marked';
export const runtime = 'edge';
interface PoemResponse {
id: string;
text: string;
createdDate: string;
imgBucketName: string;
}
export default async function Home() {
const response = await fetch("https://cloudflare-poem.gyurmatag.workers.dev/current-weather-poem");
const responseData = await response.json() as PoemResponse;
const pic = await getRequestContext().env.BUCKET.get(responseData.imgBucketName);
const timeToGenerate = await getRequestContext().env.KV.get(responseData.id.toString());
let imageSrc: string | undefined;
if (pic) {
const imageArrayBuffer = await pic.arrayBuffer();
const imageBase64 = Buffer.from(imageArrayBuffer).toString('base64');
imageSrc = `data:image/jpeg;base64,${imageBase64}`;
}
const formattedText = marked(responseData.text);
return (
<main className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
<div className="max-w-md w-full bg-white shadow-md rounded-lg p-6 space-y-4">
{imageSrc ? (
<div className="w-full">
<img src={imageSrc} alt="Cloudflare Bucket Image" className="w-full h-48 object-cover rounded-lg shadow" />
</div>
) : (
<div className="w-full h-48 bg-gray-300 rounded-lg flex items-center justify-center">
<p className="text-gray-500">Image not found</p>
</div>
)}
<div className="text-center space-y-2">
<div
className="prose prose-sm text-gray-800"
dangerouslySetInnerHTML={{ __html: formattedText }}
></div>
<p className="text-sm text-gray-600 mt-2">- {responseData.createdDate}</p>
{timeToGenerate && (
<p className="text-xs text-gray-500 mt-2">
<span className="font-semibold">Generation Time:</span> {timeToGenerate} ms
</p>
)}
</div>
</div>
</main>
);
}
Here, we first define that the service of these components takes place on an 'edge' environment, because Cloudflare's EDGE network runs on it.
After that, we retrieve the current poem, take the image from our bucket based on the name, and the generation time from the KV. Then we render everything nicely on the HTML page. For this, we will also need to properly display the markdown text generated by the AI on the card. This requires two things.
- Tailwind's Typography plugin.
- The Marked package, with which we will display the markdown format.
First, let's upload the Typography plugin:
npm install -D @tailwindcss/typography
This should also be configured in the tailwind.config.ts
file:
plugins: [
require('@tailwindcss/typography')
],
Let's also add Marked:
npm install marked
To have zero Typescript errors, the Cloudflare names, we use, must be included in the env.d.ts
file. The content of this file is automatically generated by running the cf-typegen
script in package.json
.
If we made everything right, we can already see the results of our work. If we open the application in the browser (you can also open the app from the Cloudflare dashboard), we can see something similar.
We have a beautiful poem adapted to the current weather, the related equally beautiful AI-generated image and, of course, the date with the generation duration.
Pretty cool, right? We got to know a good part of the Cloudflare stack and we have an application that we can build on top of in a fully scalable and cost-effective way. If we want to store BE separately from FE, then I think this is a pretty good solution. If we want to build a full stack app, Vercel is perfect for that.
You can find the code base of the two projects HERE and HERE.
If you have any questions, write a comment here and/or find me on X!
Happy coding! :)
Posted on August 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.