KV store

cyco130

Fatih Aygün

Posted on August 10, 2022

KV store

In the previous article, we set up our project and deployed a "Hello World" app to Cloudflare Workers. Now we're gonna look at storing and retrieving our data in Cloudflare Workers KV store. It's a simple but useful key-value store and it has a generous free tier that we can use for our project. We'll start by installing a few dependencies:

npm install -D @cloudflare/workers-types @miniflare/kv @miniflare/storage-memory
Enter fullscreen mode Exit fullscreen mode

@cloudflare/workers-types provides global type definitions for the KV API. We'll add it to our tsconfig.json file in compilerOptions.types:

{
    "compilerOptions": {
        // ... existing compiler options ...
-       "types": ["vite/client"]
+       "types": ["vite/client", "@cloudflare/workers-types"]
    }
}
Enter fullscreen mode Exit fullscreen mode

The KV API is only available on Cloudflare Workers. But, during development, Rakkas runs our app on Node.js. Fortunately, the Miniflare project has a KV implementation for Node. The other two packages that we've installed (@miniflare/kv and @miniflare/storage-memory) are what we need to be able to use the KV API during development. Let's create a src/kv-mock.ts file and create a local KV store to store our ublog posts ("twits") while testing:

import { KVNamespace } from "@miniflare/kv";
import { MemoryStorage } from "@miniflare/storage-memory";

export const postStore = new KVNamespace(new MemoryStorage());

const MOCK_POSTS = [
    {
        key: "1",
        content: "Hello, world!",
        author: "Jane Doe",
        postedAt: "2022-08-10T14:34:00.000Z",
    },
    {
        key: "2",
        content: "Hello ublog!",
        author: "Cody Reimer",
        postedAt: "2022-08-10T13:27:00.000Z",
    },
    {
        key: "3",
        content: "Wow, this is pretty cool!",
        author: "Zoey Washington",
        postedAt: "2022-08-10T12:00:00.000Z",
    },
];

// We'll add some mock posts
// Rakkas supports top level await
await Promise.all(
    // We'll do this in parallel with Promise.all,
    // just to be cool.
    MOCK_POSTS.map((post) =>
        postStore.put(post.key, post.content, {
            metadata: {
                author: post.author,
                postedAt: post.postedAt,
            },
        })
    )
);
Enter fullscreen mode Exit fullscreen mode

As you can see we also added some mock data because our application doesn't have a "create post" feature yet. This way, we can start fetching and showing some posts before we implement it.

The put method of the store accepts a key, a value, and some optional metadata. We'll use the content to store the actual post content, and the metadata to store the author and the date the post was created. The key has to be unique but other than that it is currently meaningless, we'll get to that later.

Now we should make this store available to our application's server-side code. The best place to do it is the HatTip entry which is the main server-side entry point of a Rakkas application. It's an optional file which is not part of the generated boilerplate so we'll add it manually as src/entry-hattip.ts:

import { createRequestHandler } from "rakkasjs";

declare module "rakkasjs" {
    interface ServerSideLocals {
        postStore: KVNamespace;
    }
}

export default createRequestHandler({
    middleware: {
        beforePages: [
            async (ctx) => {
                if (import.meta.env.DEV) {
                    const { postStore } = await import("./kv-mock");
                    ctx.locals.postStore = postStore;
                } else {
                    ctx.locals.postStore = (ctx.platform as any).env.KV_POSTS;
                }
            },
        ],
    },
});
Enter fullscreen mode Exit fullscreen mode

Woah, that's a lot of unfamiliar stuff. Let's break it down.

The HatTip entry is supposed to default export a HatTip request handler. So we create one with createRequestHandler. createRequestHandler accepts a bunch of options to customize the server's behavior. One of them is middleware which is used to inject middleware functions into Rakkas's request handling pipeline. HatTip middlewares are similar to Express middlewares in many ways. So the concept should be familiar if you've used Express before.

We add our middleware before Rakkas processes our application's pages (beforePages). It's, in fact, the earliest interception point. In the middleware, we inject our store into the request context object which will be available to our application's server-side code. The request context object has a locals property dedicated to storing application-specific stuff like this.

The bit starting with declare module "rakkasjs" is a TypeScript technique for extending interfaces declared in other modules. In this case, we're extending the ServerSideLocals interface which is the type of ctx.locals where ctx is the request context object.

import.meta.env.DEV is a Vite feature. Its value is true during development and false in production. Here, we use it to determine whether we should create a mock KV store or use the real one on Cloudflare Workers.

For production, HatTip's Cloudflare Workers adapter makes the so-called bindings available in ctx.platform.env. ctx.platform's type is unknown because it changes depending on the environment. So we use as any to appease the TypeScript compiler. KV_POSTS is just a name we've chosen for the binding name of our posts store.

Thanks to this fairly simple middleware, the KV store that will hold our posts will be available to our application as ctx.locals.postStore where ctx is the request context.

Fetching data from the KV store

Now we'll spin up a dev server with npm run dev and edit the src/pages/index.page.tsx file to fetch and display our mock posts. Rakkas has a very cool data fetching hook called useServerSideQuery. With this hook, you can put your server-side code right inside your components without having to create API endpoints:

import { useServerSideQuery } from "rakkasjs";

export default function HomePage() {
    const { data: posts } = useServerSideQuery(async (ctx) => {
        // This callback always runs on the server.
        // So we have access to the request context!

        // Get a list of the keys and metadata
        const list = await ctx.locals.postStore.list<{
            author: string;
            postedAt: string;
        }>();

        // Get individual posts and move things around
        // a little to make it easier to render
        const posts = await Promise.all(
            list.keys.map((key) =>
                ctx.locals.postStore
                    .get(key.name)
                    .then((data) => ({ key, content: data }))
            )
        );

        return posts;
    });

    return (
        <main>
            <h1>Posts</h1>
            <ul>
                {posts.map((post) => (
                    <li key={post.key.name}>
                        <div>{post.content}</div>
                        <div>
                            {/* post.key.metadata may not be available while testing for */}
                            {/* reasons we'll cover later. That's why we need the nullish */}
                            {/* checks here */}
                            <i>{post.key.metadata?.author ?? "Unknown author"}</i>
                            &nbsp;
                            <span>
                                {post.key.metadata
                                    ? new Date(post.key.metadata.postedAt).toLocaleString()
                                    : "Unknown date"}
                            </span>
                        </div>
                        <hr />
                    </li>
                ))}
            </ul>
        </main>
    );
}
Enter fullscreen mode Exit fullscreen mode

That's it! Now you should see a list of mock posts if you visit http://localhost:5173. Don't worry about the ugly looks yet. We'll cover styling later.

Building for production

Now build your application for production and deploy it:

npm run build
npm run deploy
Enter fullscreen mode Exit fullscreen mode

Unfortunately, if you visit your production URL now, you will get an error. That's because we haven't created a KV store on Cloudflare Workers yet. We'll do that with the wrangler CLI::

npx wrangler kv:namespace create KV_POSTS
Enter fullscreen mode Exit fullscreen mode

If everything goes well, you should see a message like this:

Add the following to your configuration file in your kv_namespaces array:
{ binding = "KV_POSTS", id = "<YOUR_KV_NAMESPACE_ID>" }
Enter fullscreen mode Exit fullscreen mode

We will do just that and we will add the following at the end of our wrangler.toml file:

[[kv_namespaces]]
binding = "KV_POSTS"
id = "<YOUR_KV_NAMESPACE_ID>"
Enter fullscreen mode Exit fullscreen mode

Then we will deploy again with npm run deploy. This time the error is gone but we'll still not see any posts. Let's add a few with wrangler CLI:

npx wrangler kv:key put --binding KV_POSTS 1 "Hello world!"
npx wrangler kv:key put --binding KV_POSTS 2 "Ooh! Pretty nice!"
npx wrangler kv:key put --binding KV_POSTS 3 "Wrangler lets us add new values to KV!"
Enter fullscreen mode Exit fullscreen mode

Unfortunately, wrangler CLI doesn't allow us to add metadata to our posts so we'll see "Unknown author" and "Unknown date" in the UI but other than that... IT WORKS, YAY! We have a working data store for our app!

You can also visit the Cloudflare Dashboard and go to Workers > KV to add/remove/edit your values in your store. If you do, you will notice that Cloudflare uses the same KV store mechanism to store your static assets.

Cleaning up

If you're going to put your code in a public repo, you should not expose your KV store ID. Just make a copy of your wrangler.toml as wrangler.example.toml and redact the KV store ID from the copy. Then add wrangler.toml to your .gitignore and run git rm wrangler.toml --cached before committing. I'm not entirely sure whether this is required but there has been a data breach in the past involving the KV store ID so it's best to play safe.

What's next?

In the next article, we'll add a form to allow users to add new posts.

You can find the progress up to this point on GitHub.

💖 💪 🙅 🚩
cyco130
Fatih Aygün

Posted on August 10, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related