Introduction
This post explores some of the basics of using Cloudflare Durable Objects (Durable Objects and Using Durable Objects).
Up until now, I haven’t had a need to use Durable Objects. Most of my use cases revolve around simple APIs and Workers KV has been sufficient for all of my needs. I’ve been meaning to experiment with Durable Objects and this is my first attempt.
While writing this example, I stumbled across itty-durable. It significantly reduces the amount of code required and seems worthy of a later look.
The Cloudflare official durable-objects-template. The code for this example will be linked in Step 5.
What is a Durable Object?
Durable Objects are named instances of a class you define. Like a class in object-oriented programming, the class defines the methods and data a Durable Object can access.
Durable Objects provide low-latency coordination and consistent storage for the Workers platform. A given namespace can support essentially unlimited Durable Objects, with each Object having access to a transactionally consistent key-value storage API.
Durable Objects consist of two components: a class that defines a template for creating Durable Objects and a Workers script that instantiates and uses those Durable Objects. The class and the Workers script are linked together with a binding.
A Durable Object remains active until all asynchronous I/O, including Promises, within the Durable Object has resolved. This is true for all HTTP and/or WebSocket connections. Durable Object state can be read and written to persistent storage using the Transactional storage API.
Durable Objects can be accessed via a Worker using alarm()
(not covered in this post) and fetch()
handler methods. The fetch()
method takes a Request
as the parameter and returns a Response
(or a Promise
for a Response
). These requests are not sent from the public Internet, but from other Workers using a Durable Object namespace binding.
Example Project
-
The project is a simple API that allows you to create
Person
object(s), retrieve the object, update the object, and delete the object. It serves no purpose other than demonstrating the basic usage of Durable Objects. -
The Durable Object in this example is going to be a
Person
. APerson
object will consist of the attributesid: number, fname: string, lname: string, age: number
. - Create the project using Cloudflare Wrangler. Navigate to the directory where you would like to create the project and run
wrangler init example-durable-object-v2
.Follow the prompts as follows:
- git (Y/N)
- package.json (Y)
- Create Worker at /src/index.ts (Y)
If you choose to manage it with git, create a
.gitignore
file at the root withnode_modules/
as the content so git does not track thenode_modules
directory.Wrangler will have generated a default worker template with the following structure.
1 2 3 4 5 6 7
example-durable-object-v2/ |- src/ |- index.ts |- pacakge-lock.json |- package.json |- tsconfig.json |- wrangler.toml
- Add
account_id
andworkers_dev
to yourwrangler.toml
configuration file as shown below.account_id
is the ID of the account associated with your zone.workers_dev
enables the use of *.workers.dev subdomain to test and deploy the Worker.
Refer to Wrangler Configuration documentation.
1 2 3 4 5 6
name = "example-durable-object-v2" main = "src/index.ts" compatibility_date = "2022-11-16" account_id = "<your account id>" workers_dev = true
- The API is going to use itty-router and will be structured as shown below. This post will not show the full project source code (which can be found here Github).
src/index.ts
is the main entry point that importsitty-router
and defines each route.src/pages/*.ts
handles the route logic for each endpoint.src/lib/person.ts
is the Durable Object class.src/lib/responses.ts
defines each Response.src/html/*.js
is boilerplate code and can be ignored.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
example-durable-object-v2/ |- src/ |- html/ |- 404.js |- index.js |- lib/ |- person.ts |- responses.ts |- pages/ |- 404.ts |- create.ts |- delete.ts |- index.ts |- person.ts |- update.ts |- index.ts |- .gitignore |- README.md |- pacakge-lock.json |- package.json |- tsconfig.json |- wrangler.toml
src/index.ts
is shown below. You need to installitty-router
which can be done by running the commandnpm install itty-router --save
in the project directory. The code is well commented and explains what each block is doing.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
export interface Env { // Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/ // MY_KV_NAMESPACE: KVNamespace; // // Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/ PERSON: DurableObjectNamespace; // // Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/ // MY_BUCKET: R2Bucket; } import home from './pages/index' import notFound from './pages/404' import person from './pages/person' import create from './pages/create' import update from './pages/update'; import del from './pages/delete'; import { Router } from 'itty-router' const router = Router() // In order for the workers runtime to find the class that implements // our Durable Object namespace, we must export it from the root module. export { Person } from './lib/person' // Routes router.get('/', (request, env, context) => home()) // Get a person router.get('/person/:id', async (request, env, context) => person(request, env, context)) // Create a person router.post('/person/:id', async (request, env, context) => create(request, env, context)) // Update a person router.put('/person/:id', async (request, env, context) => update(request, env, context)) // Delete a person router.delete('/person/:id', async (request, env, context) => del(request, env, context)) router.get('*', () => notFound()) export default { fetch: router.handle }
- In your
wrangler.toml
file create a binding for the Durable Object as shown. Further information about bindings can be found in the links below. The binding is what allows the Worker to interact with the Durable Object.1 2
[durable_objects] bindings = [{name = "PERSON", class_name = "Person"}]
src/pages/create.ts
is shown below. The code is well commented and explains what each block is doing. Further information about each block can be found by following the links below. In essence, we’re creating a new instance ofPerson
namedrequest.params.id
. We then get thestub
using theid
. We then send therequest
to thePerson
instance and wait for the response.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
import { jsonResponse } from '../lib/responses' import { jsonResponse400 } from '../lib/responses' const create = async (request: any, env: { PERSON: { idFromName: (arg0: string) => any; get: (arg0: any) => any; }; }, context: any) => { // Every unique ID refers to an individual instance of the Person class that // has its own state. `idFromName()` always returns the same ID when given the // same string as input (and called on the same class), but never the same // ID for two different strings (or for different classes). let id = env.PERSON.idFromName(request.params.id) // Construct the stub for the Durable Object using the ID. A stub is a // client object used to send messages to the Durable Object. let obj = env.PERSON.get(id) // Send a request to the Durable Object, then await its response. let resp = await obj.fetch(request) let jsonStr = await resp.json() // console.log(jsonStr) // Return the person data as json. let json = { "status": "success", "data": jsonStr, "message": "person object has been created" } return jsonResponse(JSON.stringify(json, null, 2)) } export default create
src/lib/person.ts
is the Durable Object. Thefetch()
handler takes therequest
from the Worker and uses aswitch
statement to handle eachHTTP
method. The various functions within the class handle specificPerson
logic.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
// Durable Object export class Person { state: any id: number fname: string lname: string age: number constructor(state: any, env: any) { this.state = state this.id = 0 this.fname = '' this.lname = '' this.age = 0 } // Get all person attributes getAll() { return { "id": this.id, "fname": this.fname, "lname": this.lname, "age": this.age } } // POST method logic - Set all person attributes setAll(data: any) { this.setId(data) this.setFname(data) this.setLname(data) this.setAge(data) } // PUT method logic - Update some person attributes update(data: any) { if (data.id) { this.setId(data) } if (data.fname) { this.setFname(data) } if (data.lname) { this.setLname(data) } if (data.age) { this.setAge(data) } } // DELETE method logic - Set the person attributes back to constructor values resetAll() { this.id = 0 this.fname = '' this.lname = '' this.age = 0 } // Set the person id setId(data: any) { this.id = Number(data.id) } // Set the person first name setFname(data: any) { this.fname = data.fname } // Set the person last name setLname(data: any) { this.lname = data.lname } // Set the person age setAge(data: any) { this.age = Number(data.age) } // Handle HTTP requests from clients. async fetch(request: Request) { // Durable Object storage is automatically cached in-memory, so reading the // same key every request is fast. (That said, you could also store the // value in a class member if you prefer.) let value = await this.state.storage.get("value") || 0 let data: object switch (request.method) { case "GET": value = this.getAll() break; case "POST": // Get the body data from the request data = await request.json?.() this.setAll(data) value = this.getAll() // We don't have to worry about a concurrent request having modified the // value in storage because "input gates" will automatically protect against // unwanted concurrency. So, read-modify-write is safe. For more details, // see: https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/ await this.state.storage.put("value", value) break; case "PUT": // Get the body data from the request data = await request.json?.() this.update(data) value = this.getAll() await this.state.storage.put("value", value) break; case "DELETE": // https://developers.cloudflare.com/workers/runtime-apis/durable-objects/#transactional-storage-api this.resetAll() await this.state.storage.deleteAll() break; default: return new Response("Not found", { status: 404 }); } return new Response(JSON.stringify(value)) } }
- Use
wrangler dev
to run a development server locally for testing. Publish the Worker to you account usingwrangler publish
. You will need to append the following lines towrangler.toml
before publishing the Worker.1 2 3
[[migrations]] tag = "v1" # Should be unique for each entry new_classes = ["Person"]
Testing the API
Start the development server using wrangler dev
in the project root directory. The server will be listening on http://localhost:8787
.
I’m using the Thunder Client extension for VS Code in this section.
-
GET
https://localhost:8787/person/1
should return a 400 status as there is noPerson
object. -
POST
https://localhost:8787/person/1
will create aPerson
object. You can create as manyPerson
objects as you like. -
GET
https://localhost:8787/person/1
should return a thePerson
object. -
PUT
https://localhost:8787/person/1
will update specified attributes of thePerson
object. -
GET
https://localhost:8787/person/1
should return a thePerson
object showing the updated attributes. -
DELETE
https://localhost:8787/person/1
should delete thePerson
object. -
GET
https://localhost:8787/person/1
should return a 400 status as thePerson
object was deleted.