Skip to content

avenceslau/client-side-do

Repository files navigation

Client-Side DO

Work in Progress - This project is under active development and the API is subject to change.

A proof-of-concept for client-side state management with Cloudflare Durable Objects, featuring optimistic updates and real-time synchronization via WebSockets.

Status

This project is experimental. The declarative collection client API is still being refined.

Features

  • Type-safe RPC client for Durable Objects
  • Declarative method mapping for CRUD operations
  • Optimistic updates with TanStack DB
  • Real-time sync via WebSocket subscriptions
  • Automatic reconnection with exponential backoff

Getting Started

# Install dependencies
pnpm install

# Run locally (builds app first, then starts both servers)
pnpm dev:all

Usage

Server-Side: Define a Durable Object

import { DurableObject } from 'cloudflare:workers';
import { createRpcServer, type RpcFromTarget } from './lib';

export type Todo = {
  id: string;
  title: string;
  completed: boolean;
  createdAt: string;
};

export class TodoDurableObject extends DurableObject<Env> {
  private rpcServer = createRpcServer(this);
  private todos = new Map<string, Todo>();

  async listTodos() {
    return Array.from(this.todos.values());
  }

  async createTodo(todo: Todo) {
    this.todos.set(todo.id, todo);
    return todo;
  }

  async setCompleted(id: string, completed: boolean) {
    const todo = this.todos.get(id);
    if (!todo) throw new Error('Todo not found');
    const updated = { ...todo, completed };
    this.todos.set(id, updated);
    return updated;
  }

  async deleteTodo(id: string) {
    this.todos.delete(id);
  }

  fetch(request: Request): Response {
    return this.rpcServer.fetch(request);
  }
}

// Export the RPC type for client-side usage
export type TodoRpc = RpcFromTarget<TodoDurableObject, 'fetch'>;

Client-Side: Create a Collection Client

import { useMemo } from 'react';
import { match } from 'ts-pattern';
import { useLiveQuery } from '@tanstack/react-db';
import { useQueryClient } from '@tanstack/react-query';
import { createDOClient, createDeclarativeDoCollectionClient } from './doClient';
import type { Todo, TodoRpc } from '../../src/index';

export const App = () => {
  const queryClient = useQueryClient();
  const myDO = createDOClient<TodoRpc>({ baseUrl: '/do' });

  const { collection, actions } = useMemo(
    () =>
      createDeclarativeDoCollectionClient<
        TodoRpc,
        Todo,
        string,
        'listTodos',
        'createTodo',
        'setCompleted',
        'deleteTodo'
      >(myDO, queryClient, {
        id: 'todos',
        queryKey: ['todos'],
        getKey: (item) => item.id,
        methods: {
          list: 'listTodos',
          insert: ['createTodo'],
          update: ['setCompleted'],
          remove: ['deleteTodo'],
        },
        applyInsert: (action) =>
          match(action)
            .with({ type: 'createTodo' }, ({ params: [todo] }) => todo)
            .exhaustive(),
        applyUpdate: (draft, action) => {
          match(action)
            .with({ type: 'setCompleted' }, ({ params: [, completed] }) => {
              draft.completed = completed;
            })
            .exhaustive();
        },
      }),
    [queryClient],
  );

  // Query the collection
  const { data: todos = [] } = useLiveQuery(collection);

  // Mutations are fully typed
  const handleAdd = (todo: Todo) => {
    actions.insert({ type: 'createTodo', params: [todo] });
  };

  const handleToggle = (id: string, completed: boolean) => {
    actions.update({ type: 'setCompleted', params: [id, completed] });
  };

  const handleDelete = (id: string) => {
    actions.delete({ type: 'deleteTodo', params: [id] });
  };

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => handleToggle(todo.id, !todo.completed)}
          />
          {todo.title}
          <button onClick={() => handleDelete(todo.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
};

API Overview

createRpcServer(instance)

Creates a WebSocket-based RPC server from a Durable Object instance. Automatically exposes all public methods as RPC endpoints.

createDOClient<Methods>(options)

Creates a type-safe RPC client that connects to a Durable Object via WebSocket.

const client = createDOClient<TodoRpc>({ baseUrl: '/do' });
const todoClient = client('my-todo-list');

// Direct RPC calls
const todos = await todoClient.listTodos();
await todoClient.createTodo({ id: '1', title: 'Hello', completed: false });

// Subscribe to method events
const unsubscribe = todoClient.subscribe('createTodo', (event) => {
  console.log('Todo created:', event.result);
});

// Connection status
todoClient.onStatusChange((status) => {
  console.log('Connection:', status); // 'connecting' | 'connected' | 'disconnected'
});

createDeclarativeDoCollectionClient(...)

Creates a collection client with declarative method mapping for CRUD operations. Integrates with TanStack DB for optimistic updates and TanStack Query for caching.

Config options:

  • id - Durable Object instance ID
  • queryKey - TanStack Query cache key
  • getKey - Function to extract the item key
  • methods - Maps CRUD operations to RPC method names
  • applyInsert - Returns the item to insert from an action
  • applyUpdate - Mutates the draft for optimistic updates
  • onInsert/onUpdate/onDelete - Server event handlers

Project Structure

src/
├── lib/
│   ├── index.ts          # All exports (server + client)
│   ├── client.ts         # Client-only exports (browser-safe)
│   ├── rpc.ts            # Shared types
│   ├── rpc-server.ts     # createRpcServer (Cloudflare Workers)
│   └── rpc-client.ts     # createDOClient (browser)
└── index.ts              # Durable Object + Worker

app/src/
├── doClient.ts           # Collection client utilities
└── App.tsx               # Example React app

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published