Functional-style Cloudflare Durable Objects with direct API calls from Cloudflare Workers and TypeScript support.

Overview

durable-apis

Simplifies usage of Cloudflare Durable Objects, allowing a functional programming style or class style, lightweight object definitions, and direct access to object methods from within Workers (no need for request building/handling). Heavily influenced by and loosely forked from https://github.com/kwhitley/itty-durable/.

Features

  • Removes nearly all boilerplate from writing and using Durable Objects
  • Exposes only desired APIs to be called from outside
  • First class Typescript support
  • Allows a functional programming style in addition to the object oriented style of Durable Objects
  • Extends existing APIs rather than replacing them

Example

types.ts (type definitions for your Worker and Durable Object)
export interface CounterAPI {
  get(): number
  increment(): number
  add(a: number, b: number): number
}

export interface Env {
  Counter: DurableObjectNamespaceExt<CounterAPI>
}
Counter.ts (your Durable Object function)
import { createDurable } from 'durable-apis'
import { Env } from './types'

// Functional style, pass in a function that returns an object with callable API methods
export const Counter = createDurable(({ blockConcurrencyWhile, storage }: DurableObjectState, env: Env): CounterAPI => {
  let counter = 0
  let connections = new Set<WebSocket>()
  blockConcurrencyWhile(async () => counter = (await storage.get('data')) || 0)

  // Will return the current value of counter
  function get() {
    return counter
  }

  // Will return the current value of counter
  function increment() {
    storage.put('data', ++counter)
    return counter
  }

  // Note that any serializable params can passed through from the Worker without issue.
  function add(a: number, b: number) {
    return a + b
  }

  // OPTIONAL: Handle any requests not handled by the API (avoid naming this `fetch` so we can still use the global
  // `fetch` method in our durable)
  function handleFetch(request: Request) {
    if (request.headers.get('Upgrade') === 'websocket') {
      const [ client, server ] = Object.values(new WebSocketPair())
      server.accept()
      connections.add(server)
      server.addEventListener('close', () => connections.delete(server))
      server.addEventListener('message', ({ data }) => connections.forEach(conn => conn.send(data)))

      return new Response(null, {
        status: 101,
        webSocket: client,
      })
    }
    return new Response(null)
  }

  // Only public-facing API will be exposed for calling from Workers
  // Adding a fetch method will catch any requests not handled by the API, allowing for Websocket request handling, etc.
  return { get, increment, add, fetch: handleFetch }
})
Worker.js (your CF Worker function)
import { ThrowableRouter, missing, StatusError } from 'itty-router-extras'
import { withDurables } from 'durable-apis'
import { Env } from './types'

// export the durable class, per spec
export { Counter } from './Counter'

const router = ThrowableRouter({ base: '/counter' })

router
  // add upstream middleware, allowing Durable access off the request
  .all('*', withDurables())

  // get the durable value...
  .get('/', (req: Request, { Counter }: Env) => Counter.get('test').get())

  // returns the value from the method
  .get('/increment', (req: Request, { Counter }: Env) => Counter.get('test').increment())

  // you can pass any serializable params to a method... (e.g. /counter/add/3/4 => 7)
  .get('/add/:a?/:b?',
    ({ params: { a, b }}: Request, { Counter }: Env) => Counter.get('test').add(Number(a), Number(b))
  )

  // use fetch like normal when direct APIs aren't enough, such as when handling a websocket upgrade
  .get('/ws', (req: Request, { Counter }: Env) => {
    if (request.headers.get('Upgrade') !== 'websocket') {
      throw new StatusError(426, 'Expected Upgrade: websocket')
    }
    return Counter.get('test').fetch(req)
  })

  // 404 for everything else
  .all('*', () => missing('Are you sure about that?'))

// with itty, and using ES6 module syntax (required for DO), this is all you need
export default {
  fetch: router.handle
}

/*
Example Interactions:

GET /counter                                => 0
GET /counter/increment                      => 1
GET /counter/increment                      => 2
GET /counter/increment                      => 3
GET /counter/add/20/3                       => 23
*/
Alternative class-style Counter.ts (your Durable Object Class)
import { createDurable } from 'durable-apis'
import { Env } from './types'

// If you prefer the Class style, use a class like you normally would, wrapping it in our durable handler.
// Note: all class methods are callable remotely using this method.
export const Counter = createDurable(class Counter {
  state: DurableObjectState
  env: Env
  counter: number
  connections: Set<WebSocket>

  constructor(state: DurableObjectState, env: Env): CounterAPI => {
    this.state = state
    this.env = env
    this.counter = 0
    this.connections = new Set()
    state.blockConcurrencyWhile(async () => counter = (await state.storage.get('data')) || 0)
  }

  // Will return the current value of counter
  get() {
    return this.counter
  }

  // Will return the current value of counter
  increment() {
    this.state.storage.put('data', ++this.counter)
    return this.counter
  }

  // Note that any serializable params can passed through from the Worker without issue.
  add(a: number, b: number) {
    return a + b
  }

  // OPTIONAL: Handle any requests not handled by the API (avoid naming this `fetch` so we can still use the global
  // `fetch` method in our durable)
  fetch(request: Request) {
    if (request.headers.get('Upgrade') === 'websocket') {
      const [ client, server ] = Object.values(new WebSocketPair())
      server.accept()
      this.connections.add(server)
      server.addEventListener('close', () => this.connections.delete(server))
      server.addEventListener('message', ({ data }) => this.connections.forEach(conn => conn.send(data)))

      return new Response(null, {
        status: 101,
        webSocket: client,
      })
    }
    return new Response(null)
  }
})

How it Works

This library works via a two part process:

  1. First of all, we create a function to implement your Durable Object (through createDurable()). This embeds a tiny internal itty-router to handle fetch requests. Using this removes the boilerplate from your objects themselves, allowing them to be only business logic. Durable Objects defined on your env object will be extended automatically the same way withDurables() does inside Workers.

  2. Next, we expose the withDurables() middleware for use within your Workers (it is designed for drop-in use with itty-router, but should work with virtually any typical Worker router, and extendEnv() can be used with no router). This replaces your stubs on the env object with extended versions of themselves. Using these extended stubs, you can call methods on the Durable Object directly, rather than manually creating fetch requests to do so (that's all handled internally, communicating with the embedded router within the Durable Objects themselves).

Installation

npm install durable-apis

API

createDurable((state: DurableObjectState, env: Env)): Function

Factory function to create the DurableApi function that wraps your API.

Note: if you're confused at how a function can replace a class, it is a simple quirk with how JavaScript treats classes. If something is returned from the constructor of a class, it is returned in the new Class() assignment. This means for a function myFunc() that returns a value, the two statements obj = myFunc() and obj = new myFunc() are equal.

withDurables(): function

Recommended middleware to extend Durable Object namespaces with an updated get() method on the env object. Using the new stubs returned allows you to skip manually creating/sending requests or handling response parsing.

extendEnv(env: Env): Env

If not using middleware, use this to extend the Durable Object stubs get() method on the env object with an updated version that will let you call API methods directly on the stub. Using these stubs allows you to skip manually creating/sending requests or handling response parsing.

DurableObjectNamespace.get(id?: string | DurableObjectId): DurableObjectNamespaceExt

The new get() method will still work the way it did before, taking a DurableObjectId and returning a stub that you can call fetch() on. But you can also call other methods on the stub which will be proxied to the Durable Object. In addition, if you pass a string to get() that is 64 characters long (the length of a Durable Object id), it will automatically convert that to an id object (using idFromString()) and return the stub. If you pass a string that is not 64 characters long, it will be treated as the name and converted to the id (using idFromName) before returning the stub. If you pass nothing, a new id will be created (using newUniqueId()) and the stub will be returned.

DurableObjectNamespaceExt<T>

Typescript interface which will add the interface methods for T to the stub proxy. This allows Typescript users to define their env object with the specific APIs that their Durable Objects implement, helping avoid errors in code. The methods return types are all wrapped in Promises automatically if the method does not already return a Promise since calling a Durable Object will always be an asyncronous operation.

Special Thanks

Thanks to Kevin Whitley for the Itty Router library and for work on Itty Durable which this was modeled after.

You might also like...

An esbuild plugin for simplifying global API calls.

esbuild-plugin-global-api This plugin is still experimental, not recommended for production. It may break your code in some cases. An esbuild plugin f

Nov 15, 2022

Query for CSS brower support data, combined from caniuse and MDN, including version support started and global support percentages.

css-browser-support Query for CSS browser support data, combined from caniuse and MDN, including version support started and global support percentage

Nov 2, 2022

A utility for creating toggleable items with JavaScript. Inspired by bootstrap's toggle utility. Implemented in vanillaJS in a functional style.

LUX TOGGLE Demo: https://jesschampion.github.io/lux-toggle/ A utility for creating toggleable dom elements with JavaScript. Inspired by bootstrap's to

Oct 3, 2020

Abusing Cloudflare Workers to establish persistence and exfiltrate sensitive data at the edge.

Abusing Cloudflare Workers This repository contains companion code for the blog post MITM at the Edge: Abusing Cloudflare Workers. malicious-worker/ c

Sep 16, 2022

A Cloudflare Workers service that fetches and renders Notion pages as HTML, Markdown, or JSON.

notion-fetch A Cloudflare Workers service that fetches and renders Notion pages as HTML, Markdown, or JSON. Powered by Durable Objects and R2. Usage P

Jan 6, 2023

A flexible gateway for running ML inference jobs through cloud providers or your own GPU. Powered by Replicate and Cloudflare Workers.

Cogflare (Working title) Cogflare is a Cloudflare Workers application that aims to simplify running distributed ML inference jobs through a central AP

Dec 12, 2022

A zero-dependency, strongly-typed web framework for Bun, Node and Cloudflare workers

nbit A simple, declarative, type-safe way to build web services and REST APIs for Bun, Node and Cloudflare Workers. Examples See some quick examples b

Sep 16, 2022

A URL shortener that runs on Cloudflare Workers

ITP Works A URL shortener that runs on Cloudflare Workers. It stores the rules in Cloudflare KV storage and sends a 301 redirect when a matched pathna

Mar 4, 2022

Starting template for building a Remix site with CloudFlare Workers (ES Modules Syntax)

Starting template for building a Remix site with CloudFlare Workers (ES Modules Syntax)

May 20, 2022
Comments
  • 500: Illegal invocation

    500: Illegal invocation

    Hello! Trying to implement UsersAPI ...

    export interface UserAPI { signIn(user: SignInputSchema): ErrorResponse | SuccessResponse getInfo(): UserSchema | undefined getLogins(): UserLoginSchema[] test(): any }

    export interface Env { User: DurableObjectNamespaceExt }

    router .all('*', withDurables) .get('/auth/test', async (request: IRequest, { User }: Env) => { return User.get('test').test() })

    It's example of a simplest interaction, but all seems like all durable calls ends up with { "status": 500, "error": "Illegal invocation" }

    In User class .... function test() { return 'Justa test...' } ... and everything exports without errors (has all defined functions, etc) and everything cooked by your tutorial... but Sorry i'm stuck here, any ideas??

    opened by sinitsa 1
Owner
Dabble
Tools for writing
Dabble
Transactional Inbox/Outbox pattern for Durable Objects

do-transactional-outbox One of the challenges that many event-driven systems face is the fact that they have to write to the database and send out an

Erwin van der Koogh 5 Sep 27, 2022
App to manage maintenance calls. App to manage maintenance calls. This application was created for the purpose of studies.

App to manage maintenance calls. App to manage maintenance calls. This application was created for the purpose of studies.

Rodrigo Gonçalves 112 Dec 26, 2022
Google-Drive-Directory-Index | Combining the power of Cloudflare Workers and Google Drive API will allow you to index your Google Drive files on the browser.

?? Google-Drive-Directory-Index Combining the power of Cloudflare Workers and Google Drive will allow you to index your Google Drive files on the brow

Aicirou 127 Jan 2, 2023
Lightweight universal Cloudflare API client library for Node.js, Browser, and CF Workers

Cloudflare API Client Lightweight universal HTTP client for Cloudflare API based on Fetch API that works in Node.js, browser, and CF Workers environme

Kriasoft 15 Nov 13, 2022
A simple firefox/chrome extension adds Sci-Hub direct link access on publishing websites

Sci-Hub injector extension Supported sites PubMed Nature Science Direct Taylor & Francis Springer Link (article, book, chapter, protocol, reference wo

Dany 15 May 7, 2022
🔻 Generate a Google Drive direct download link based on the URL or ID

Drive Link Generate a Google Drive direct download link based on the URL or ID. Usage The API is the same on all this platforms ✔️ Deno ?? import { dr

Eliaz Bobadilla 10 Nov 1, 2022
基于 gh-proxy + Jsdelivr+ cnpmjs + cloudflare workers 的 GitHub Serverless API 工具。

better-github-api Better, Eazy, Access Anywhere 介绍 基于 gh-proxy + Jsdelivr + cnpmjs + cloudflare workers 的 GitHub Serverless API 工具。 cdn.js:仅含 gh-proxy

One Studio 11 Nov 23, 2022
Dead-simple CORS handling for any itty-router API (test with Cloudflare Workers, but works anywhere)!

Simple CORS-handling for any itty-router API. Designed on Cloudflare Workers, but works anywhere. Features Tiny. Currently ~600 bytes, with zero-depen

Kevin R. Whitley 6 Dec 16, 2022
It's a simple Leaderboard with the functionality to add and view leaderboard with API calls

It's a simple Leaderboard with the functionality to add and view leaderboard with API calls. This project follows GitFlow instead of GitHub flow and it's built with JavaScript, CSS and HTML.

Robertson Arthur 4 Jul 6, 2022