Storing state between serverless requests with TypeScript and Spin
Tags: webassembly, serverless, tutorial, typescript
Published: 2023-10-24 20:00:18

One frequently cited frustration with using serverless or Functions as a Service (FaaS) is storing state between requests. This article shows how to use Spin's built-in key value store to store and retrieve JSON data between requests.

> Spin is an [open source tool](https://github.com/fermyon/spin) for creating server-side WebAssembly apps. 

If you are new to Spin, we'll cover most of the basics here. But you can also check out the [official quickstart](https://developer.fermyon.com/spin/quickstart) if you'd prefer. Spin supports other languages, including Rust, Python, and Go (and, of course, JavaScript). And the technique shown here works for those languages as well. But here we will focus on TypeScript.

To do this project, you will need:
* The [latest version](https://developer.fermyon.com/spin/install) of Spin
* The [NPM](https://docs.npmjs.com/) CLI

For our project, we're going to create a very simple page view counter that will increment each time the page has been accessed.

## Starting a New Project

To get started, let's create a new Spin TypeScript project named `view-counter`:

```console
$ spin new http-ts view-counter --accept-defaults
```

The above will create a new HTTP handler for TypeScript (thus `http-ts`) named `view-counter`, and we're just accepting the default configuration instead of walking through the wizard.

After this command, you should have a `view-counter/` directory that looks like this:

```console
$ tree view-counter/
view-counter/
├── README.md
├── package.json
├── spin.toml
├── src
│   └── index.ts
├── tsconfig.json
└── webpack.config.js

1 directory, 6 files
```

We need to `cd` into the `view-counter/` directory and initialize the project with `npm`:

```console
$ cd view-counter
$ npm install

added 141 packages, and audited 142 packages in 6s

20 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
```

Since we'll be using key value storage, we need to enable that feature for our app.

## Enabling Key Value Storage

Before writing any code, we need to make one change to the `spin.toml` file. This is the main configuration file for Spin. Since WebAssembly has a capabilities-based security model, any time you want to enable a capability (key value storage), you need to tell Spin it is okay to allow the code to use that capability.

So in `spin.toml`, we'll add one line to the `[[component]]` declaration:

```toml
[[component]]
id = "view-counter"
source = "target/view-counter.wasm"
exclude_files = ["**/node_modules"]
key_value_stores = ["default"]   # <-- Enable default KV storage
[component.trigger]
route = "/..."
[component.build]
command = "npm run build"
```

We just set `key_value_stores = ["default"]` to enable the default key value store.

With that, we are ready to code!

## Editing the `index.ts`

Next up, we will be editing `src/index.ts`. When we ran `spin new`, it created the following for us:

```typescript
import { HandleRequest, HttpRequest, HttpResponse} from "@fermyon/spin-sdk"

const encoder = new TextEncoder()

export const handleRequest: HandleRequest = async function(request: HttpRequest): Promise<HttpResponse> {
    return {
      status: 200,
        headers: { "foo": "bar" },
      body: encoder.encode("Hello from TS-SDK").buffer
    }
}
```

The first thing we are going to do is simplify it by removing some unnecessary example code:

```typescript
import { HandleRequest, HttpRequest, HttpResponse } from "@fermyon/spin-sdk"

export const handleRequest: HandleRequest = async function (request: HttpRequest): Promise<HttpResponse> {
  let body = "Hello Dev.to World!"
  return {
    status: 200,
    body: body
  }
}
```

Now our function just returns a simple response. Next up, let's create a simple class that represents a page view.

```
class PageView {
  path: string = "";
  count: number = 0;
}
```

This class isn't particularly sophisticated, but it will serve our purpose of showing how to store JSON data in key value storage.

Now we can write a function that stores and retrieves the record.

```typescript
import { Kv, HandleRequest, HttpRequest, HttpResponse } from "@fermyon/spin-sdk"

// snip

const logRequest = async function (path: string): Promise<number> {
  let store = Kv.openDefault()

  // If there is already a record for this path, updated it and re-store it.
  if (store.exists(path)) {
    let view = store.getJson(path)
    view.count++
    store.setJson(path, view)
    return view.count;
  }

  // Otherwise, create a new record for this path.
  let new_view: PageView = {
    path: path,
    count: 1
  }
  store.setJson(path, new_view)
  return 1
}
```

Note that we added `Kv` to the `import` line now. That's the main key value store object.

First, we open the default key value store with `Kv.openDefault()`. This gives us a handle to the key value storage system. We'll use three functions: 
* `exists()` to see if a key exists
* `getJson()` to get a JSON object by name
* `setJson()` to store a name and JSON object as a pair

The flow of the code goes like this:

1. If the given path exists in storage:
  1. Get back that path's object (which is a `PageView` object)
  1. Increment the `view` count
  1. Store the updated record (which will overwrite the previous one)
  1. Return the view count
1. If the path does not exist:
  1. Create a new `PageView`
  1. Save it in storage
  1. Return `1` (since we know this is the first page view)

Now we can update our `handleRequest()` function to call `logRequest()`:

```typescript
export const handleRequest: HandleRequest = async function (request: HttpRequest): Promise<HttpResponse> {
  let count = await logRequest(request.uri)
  let body = `The path ${request.uri} has been accessed ${count} time(s).`
  return {
    status: 200,
    body: body
  }
}

```

In this updated code, we've just added the line `let count = await logRequest(request.uri)` and then updated the `body` variable to have the path and the count.

We can locally build and run our app in one command:

```console
$ spin build --up
Building component view-counter with `npm run build`

> view-counter@1.0.0 build
> npx webpack --mode=production && mkdir -p target && spin js2wasm -o target/view-counter.wasm dist/spin.js

asset spin.js 13.3 KiB [emitted] (name: main)
orphan modules 5.91 KiB [orphan] 11 modules
runtime modules 937 bytes 4 modules
cacheable modules 10.2 KiB
  ./src/index.ts + 5 modules 9.61 KiB [built] [code generated]
  ./node_modules/typedarray-to-buffer/index.js 646 bytes [built] [code generated]
webpack 5.89.0 compiled successfully in 689 ms

Starting to build Spin compatible module
Preinitiating using Wizer
Optimizing wasm binary using wasm-opt
Spin compatible module built successfully
Finished building all Spin components
Logging component stdio to ".spin/logs/"
Storing default key-value data to ".spin/sqlite_key_value.db"

Serving http://127.0.0.1:3000
Available Routes:
  view-counter: http://127.0.0.1:3000 (wildcard)
```

We could test in a Web browser or just use `curl`:

```console
$ curl localhost:3000/            
The path / has been accessed 1 time(s).
$ curl localhost:3000/            
The path / has been accessed 2 time(s).
```

Now, here's a fun side-effect of the way we built our app. Since we left the default settings when we did a `spin new`, our app will answer on any path under `/`. So we can make a request to another path, and the same function will be called. But since we're storing the hit counter by path, we should see it go back to `1`:

```console
$ curl localhost:3000/hello       
The path /hello has been accessed 1 time(s).
```

Effectively, we now have two keys stored in key value storage: `/` and `/hello`. Each has its own counter.

## Deploying to the Cloud

This part is optional, but shows you how key value storage works in a prod-like environment.

Deploying to Fermyon Cloud requires only a GitHub OAuth handshake (which I already did prior to this example using `spin login`). From there, we can use a simple command, `spin deploy` to push our app to the cloud:

```console
$ spin deploy    
Uploading view-counter version 0.1.0-rc8562996 to Fermyon Cloud...
Deploying...
Waiting for application to become ready.......... ready
Available Routes:
  view-counter: https://view-counter-djfdh6p3.fermyon.app (wildcard)
```

Now we have a public URL that anyone can hit.

```console
$ curl https://view-counter-djfdh6p3.fermyon.app                     
The path / has been accessed 1 time(s).
```

Note that the counter reset to `1`. That's because when we deployed our app, Fermyon automatically provisioned us an in-cloud key value store. I now have one storage copy locally, and one in the cloud. And the one in the cloud is production-grade.

## Wrap-up

Saving state in Spin applications is easy using the built-in key value store. Here we built a simple page counter that stored JSON objects for each request path. And we did all of this without needing to install or manage another external service. It's just part of Spin.

To learn more about key value storage, the best place to check is [the official documentation](https://developer.fermyon.com/spin/kv-store-api-guide). And to get some ideas of what you can build with Spin, check out the [Spin Up Hub](https://developer.fermyon.com/hub). And if you find any bugs or want to hack on the Spin source code, it's all [on GitHub](https://github.com/fermyon/spin).