DEV Community

loading...
Cover image for cloudflare durable objects

cloudflare durable objects

ajcwebdev profile image anthonyCampolo ・6 min read

Durable Objects provide low-latency coordination and consistent storage for the Workers platform through two features:

  • Global Uniqueness guarantees that there will be a single Durable Object with a given id running at once, across the whole world.

  • The transactional storage API provides strongly-consistent, key-value storage to the Durable Object. Each Object can only read and modify keys associated with that Object.

Using Durable Objects

Durable Objects are named instances of a class you define. The class defines the methods and data a Durable Object can access. There are four steps to creating a Durable Object:

  • Writing the class that defines a Durable Object
  • Configuring the class as Durable Object namespace and uploading it to Cloudflare's servers
  • Binding that namespace into a Worker
  • Instantiating and communicating with a Durable Object from a running Worker via the Fetch API

Writing a class that defines a Durable Object

To define a Durable Object, developers export an ordinary JavaScript class (other languages will need a JavaScript class shim).

  • The first parameter passed to the class constructor contains state specific to the Durable Object, including methods for accessing storage.
  • The second parameter contains any bindings you have associated with the Worker when you uploaded it.
export class DurableObjectExample {
    constructor(state, env) {
    }
}
Enter fullscreen mode Exit fullscreen mode

Workers communicate with a Durable Object via the fetch API. A Durable Object listens for incoming Fetch events by registering an event handler.

export class DurableObjectExample {
    constructor(state, env) {
    }

    async fetch(request) {
        return new Response('Hello World');
    }
}
Enter fullscreen mode Exit fullscreen mode

HTTP requests received by a Durable Object do not come directly from the Internet. They come from other Worker code through other Durable Objects, or just plain Workers. A Worker can pass information to a Durable Object via:

  • Headers
  • HTTP method
  • Request body
  • Request URI

Accessing Persistent Storage from a Durable Object

The persistent storage API is accessed via the first parameter passed to the Durable Object constructor. Request executions can still interleave with each other while waiting on I/O even though access to a Durable Object is single-threaded.

export class DurableObjectExample {
    constructor(state, env) {
        this.storage = state.storage;
    }

    async fetch(request) {
        let ip = request.headers.get('CF-Connecting-IP');
        let data = await request.text();
        let storagePromise = this.storage.put(ip, data);
        await storagePromise;

        return new Response(ip + ' stored ' + data);
    }
}
Enter fullscreen mode Exit fullscreen mode

Each individual storage operation behaves like a database transaction.

More complex use cases can wrap multiple storage statements in a transaction. This actor puts a key if and only if its current value matches the provided "If-Match" header value:

export class DurableObjectExample {
    constructor(state, env) {
        this.storage = state.storage;
    }

    async fetch(request) {
        let key = new URL(request.url).host
        let ifMatch = request.headers.get('If-Match');
        let newValue = await request.text();
        let changedValue = false;

        await this.storage.transaction(async txn => {
            let currentValue = await txn.get(key);
            if (currentValue != ifMatch && ifMatch != '*') {
                txn.rollback();
                return;
            }
            changedValue = true;
            await txn.put(key, newValue);
        });

        return new Response("Changed: " + changedValue);
    }
}
Enter fullscreen mode Exit fullscreen mode

Transactions operate at a serializable isolation level, meaning transactions can fail if they conflict with a concurrent transaction being run by the same Durable Object. To avoid transaction conflicts:

  • Don't use transactions when you don't need them
  • Don't hold transactions open any longer than necessary
  • Limit the number of key-value pairs operated on by each transaction

In-memory state in a Durable Object

Variables in a Durable Object will maintain state as long as your Durable Object is not evicted from memory.

Initialize an object from persistent storage and set class variables the first time it is accessed. Future accesses are routed to the same object making it possible to return any initialized values without making further calls to persistent storage.

export class Counter {
    constructor(state, env) {
        this.storage = state.storage;
    }

    async initialize() {
        let stored = await this.storage.get("value");
        this.value = stored || 0;
    }

    async fetch(request) {
        if (!this.initializePromise) {
            this.initializePromise = this.initialize();
        }
        await this.initializePromise;
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Defining a Durable Object namespace

The following describes the raw HTTP API to:

  • Upload your class definition
  • Define a Durable Object namespace
  • Bind another worker to be able to talk to it

A helper script is included to handle creating configuring and uploading the script.

// durable-object-example.mjs

export class DurableObjectExample {
    constructor(state, env) {
        this.state = state;
    }

    async fetch(request) {
        let ip = request.headers.get('CF-Connecting-IP');
        let data = await request.text()  || "No data!";
        let storagePromise = this.state.storage.put(ip, data);
        await storagePromise;

        return new Response(ip + ' stored ' + data);
    }

}

export default {
    async fetch(request, env) {
        return new Response("Hello World");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now that we have a class, we need to tell Cloudflare to associate this class with a Durable Object namespace so that the runtime can create instances of this class and allow Workers to contact those instances. Save the class in a file named durable-object-example.mjs.

Durable Objects are written using a new kind of Workers syntax based on ES Modules (export class DurableObjectExample). The export statement makes the class visible to the system, so that the Workers Runtime can instantiate it directly.

To upload Workers written with this new syntax you must first define a metadata file.

// durable-object-example.json

{
    "main_module": "durable-object-example.mjs"
}
Enter fullscreen mode Exit fullscreen mode

Now we can upload the script that defines the class, where API_TOKEN is your Workers API Token, ACCOUNT_TAG is your Account ID, and script name is your chosen script name:

$ curl -i -H "Authorization: Bearer ${API_TOKEN}" "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_TAG}/workers/scripts/${SCRIPT_NAME}" -X PUT -F "metadata=@durable-object-example.json;type=application/json" -F "script=@durable-object-example.mjs;type=application/javascript+module"
Enter fullscreen mode Exit fullscreen mode

If your Durable Object and Worker are in separate files, the endpoint will return an error: The uploaded script has no registered event handlers. To get around this, add a stub event handler to durable-object-example.mjs.

export default {
    async fetch(request, env) {
        return new Response("Hello World");
    }
}
Enter fullscreen mode Exit fullscreen mode

The script containing the class now exists on Cloudflare's servers, so we can tell Cloudflare that this script contains a Durable Object class.

$ curl -i -H "Authorization: Bearer ${API_TOKEN}" "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_TAG}/workers/durable_objects/namespaces" -X POST --data "{\"name\": \"example-class\", \"script\": \"${SCRIPT_NAME}\", \"class\": \"DurableObjectExample\"}"
Enter fullscreen mode Exit fullscreen mode

The new namespace's ID will be returned in the response; save this for use below.

Binding to the Durable Object namespace

In order for Workers to talk to instances of this class, they need an environment binding for it. A Durable Object namespace binding is a named global variable that appears in your Worker that provides access to instances of your Durable Object.

Here's a basic Worker script that always forwards all requests to the object named "foo". Our binding for our namespace shows up as a global called EXAMPLE_CLASS.

// calling-worker.js

addEventListener("fetch", event => {
    return event.respondWith(handle(event.request));
});

async function handle(request) {
    let objectId = EXAMPLE_CLASS.idFromName("foo");
    let object = EXAMPLE_CLASS.get(objectId);

    return object.fetch(request);
}
Enter fullscreen mode Exit fullscreen mode

You will again need to specify metadata using the namespace id from above in order to define the binding.

// calling-worker.json

{
  "body_part": "script",
  "bindings": [
    {
      "type": "durable_object_namespace",
      "name": "EXAMPLE_CLASS",
      "namespace_id": <the namespace id from above>
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Upload your worker with CALLING_SCRIPT_NAME as the name for your calling worker:

$ curl -i -H "Authorization: Bearer ${API_TOKEN}" "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_TAG}/workers/scripts/${CALLING_SCRIPT_NAME}" -X PUT -F "metadata=@calling-worker.json;type=application/json" -F "script=@calling-worker.js;type=application/javascript+module"
Enter fullscreen mode Exit fullscreen mode

In this example, we have used the old, non-modules-based syntax when defining our calling worker. In the new modules-based syntax, the binding EXAMPLE_CLASS would not show up as a global variable, but would instead be delivered as a property of the environment object passed as the second parameter when an event handler or class constructor is invoked.

If you deploy your calling worker and make a request to it, you'll see that your request was stored in the Durable Object.

$ curl -H "Content-Type: text/plain" https://calling-worker.<your-namespace>.workers.dev/ --data "important data!"
***.***.***.*** stored important data!
Enter fullscreen mode Exit fullscreen mode

Instantiating and communicating with a Durable Object

When a Worker talks to a Durable Object, it does so through a "stub" object. The class binding's get() method returns a stub, and the stub's fetch() method sends Requests to the Durable Object instance.

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  let id = EXAMPLE_CLASS.idFromName(new URL(request.url).pathname);

  let stub = await EXAMPLE_CLASS.get(id);

  let response = await stub.fetch(request);

  return response;
}
Enter fullscreen mode Exit fullscreen mode

In the above example, we used a string-derived object ID. You can also ask the system generate random unique IDs. System-generated unique IDs have better performance characteristics, but require that you store the ID somewhere in order to access the object again later.

Example - Counter

Worker

export default {
    async fetch(request, env) {
        return await handleRequest(request, env);
    }
}

async function handleRequest(request, env) {
    let id = env.Counter.idFromName("A");
    let obj = env.Counter.get(id);
    let resp = await obj.fetch(request.url);
    let count = await resp.text();

    return new Response("Durable Object 'A' count: " + count);
}
Enter fullscreen mode Exit fullscreen mode

Durable Object

export class Counter {
    constructor(state, env) {
        this.storage = state.storage;
    }

    async initialize() {
        let stored = await this.storage.get("value");
        this.value = stored || 0;
    }

    async fetch(request) {
        if (!this.initializePromise) {
            this.initializePromise = this.initialize();
        }

        await this.initializePromise;

        let url = new URL(request.url);

        switch (url.pathname) {
          case "/increment":
              ++this.value;
              await this.storage.put("value", this.value);
              break;
          case "/decrement":
              --this.value;
              await this.storage.put("value", this.value);
              break;
          case "/":
              break;
          default:

            return new Response("Not found", {status: 404});
        }

        return new Response(this.value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Discussion

pic
Editor guide