Skip to content

rocicorp/hello-zero-cf

Repository files navigation

Hello Zero Cloudflare

This demo shows how to run Zero in a Cloudflare Workers + Durable Objects environment. It demonstrates:

  • A React web UI using Zero
  • Using Hono to implement Zero's API requirements and auth
  • A Durable Object running Zero as another client for live monitoring

Why run Zero Client in a Durable Object!?

Imagine you're running collaboration sessions in DOs and need to reliably control their lifecycle. Instead of unreliably sending shutdown messages to every DO, you can write state to Postgres and have the DOs sync that state. The DO monitors what state it should be in and acts accordingly.

More generally, any time a DO needs some subset of Postgres data, it's useful to have a live-updated, consistent view rather than repeatedly querying.

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Browser (http://localhost:5173)                        β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚  React UI + Zero Client                         β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚
                         ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Zero Cache (http://localhost:4848)                     β”‚
β”‚  - Proxies queries to Worker endpoints                  β”‚
β”‚  - Manages replica state                                β”‚
β”‚  - Streams changes from Postgres                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        ↓                ↓                ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  /api/       β”‚  β”‚  /api/       β”‚  β”‚  /api/       β”‚
β”‚  get-queries β”‚  β”‚  mutate      β”‚  β”‚  do/init     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚                β”‚                β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Cloudflare Worker (via Vite Plugin)                    β”‚
β”‚  - Hono server handling API routes                      β”‚
β”‚  - Durable Object (ZeroDO)                              β”‚
β”‚    └─ Zero Client monitoring messages                   β”‚
β”‚       └─ Prints live table to console                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚
                         ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Postgres (localhost:5432)                              β”‚
β”‚  - Source of truth                                      β”‚
β”‚  - Logical replication enabled                          β”‚
β”‚  - User and Message tables                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Running the Demo

You'll need 3 terminals to run this example!

1. Start Postgres

# Terminal 1
npm run dev:db-up

2. Start Zero Cache

# Terminal 2
npm run dev:zero-cache

3. Start UI

# Terminal 3
npm run dev:ui

The Vite dev server (with Cloudflare plugin) runs your Worker code locally, handling both the React UI and API endpoints.

Open a browser at http://localhost:5173 to:

  • Add/edit/delete messages
  • Login/logout (randomly assigns you a user)
  • Filter messages by sender or text content

3. Trigger the Durable Object

Once the UI is running, trigger the DO to start monitoring messages:

curl http://localhost:5173/api/do/init

Or open http://localhost:5173/api/do/init in your browser.

The DO will start printing a live-updating table of messages to Terminal 3 (where dev:ui is running). As you add, edit, or delete messages in the web UI, you'll see the DO's console output update in real-time!

Project Structure

hello-zero-cf/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ shared/              # Code shared between client and server
β”‚   β”‚   β”œβ”€β”€ schema.ts        # Zero schema (User, Message tables)
β”‚   β”‚   β”œβ”€β”€ queries.ts       # Synced queries
β”‚   β”‚   β”œβ”€β”€ mutators.ts      # Custom mutators (create, delete, update)
β”‚   β”‚   β”œβ”€β”€ auth.ts          # Shared auth constants
β”‚   β”‚   └── must.ts          # Utility for null checking
β”‚   β”œβ”€β”€ worker/              # Cloudflare Worker code
β”‚   β”‚   β”œβ”€β”€ index.ts         # Hono app with routes
β”‚   β”‚   β”œβ”€β”€ zero-do.ts       # Durable Object with Zero client
β”‚   β”‚   β”œβ”€β”€ login.ts         # Authentication handlers
β”‚   β”‚   β”œβ”€β”€ mutate.ts        # Mutation endpoint
β”‚   β”‚   └── get-queries.ts   # Query endpoint
β”‚   └── react-app/           # React UI
β”‚       β”œβ”€β”€ App.tsx          # Main app component
β”‚       β”œβ”€β”€ main.tsx         # Entry point with ZeroProvider
β”‚       └── ...
β”œβ”€β”€ db/
β”‚   β”œβ”€β”€ docker-compose.yml   # Postgres with replication config
β”‚   └── seed.sql             # Database schema and seed data
β”œβ”€β”€ wrangler.json            # Cloudflare Workers config
└── .env                     # Environment variables

Key Features

Synced Queries

The demo uses Zero's synced queries API (not legacy ad-hoc queries):

// src/shared/queries.ts
export const queries = {
  users: syncedQuery("users", z.tuple([]), () => {
    return builder.user;
  }),
  messages: syncedQuery("messages", z.tuple([]), () => {
    return builder.message.related("sender").orderBy("timestamp", "desc");
  }),
  filteredMessages: syncedQuery(
    "filteredMessages",
    z.tuple([z.object({ senderID: z.string(), body: z.string() })]),
    ({ senderID, body }) => {
      let query = builder.message.related("sender");
      if (senderID) query = query.where("senderID", senderID);
      if (body) query = query.where("body", "LIKE", `%${escapeLike(body)}%`);
      return query.orderBy("timestamp", "desc");
    }
  ),
};

Custom Mutators

Server-side mutators with authentication checks:

// src/shared/mutators.ts
export function createMutators(userID?: string) {
  return {
    message: {
      async create(tx: Transaction<Schema>, message: Message) {
        await tx.mutate.message.insert(message);
      },
      async delete(tx: Transaction<Schema>, id: string) {
        mustBeLoggedIn(userID);
        await tx.mutate.message.delete({ id });
      },
      async update(tx: Transaction<Schema>, message: MessageUpdate) {
        mustBeLoggedIn(userID);
        const prev = await tx.query.message.where("id", message.id).one().run();
        if (!prev) return;
        if (prev.senderID !== userID) {
          throw new Error("Must be sender of message to edit");
        }
        await tx.mutate.message.update(message);
      },
    },
  };
}

Cookie-Based Auth

Uses Hono's signed cookies (no JWT library needed):

// Server: src/worker/login.ts
await setSignedCookie(c, AUTH_COOKIE_NAME, userID, secretKey);

// Client: src/react-app/main.tsx
const signedCookie = Cookies.get(AUTH_COOKIE_NAME);
const userID = signedCookie && signedCookie.split(".")[0];

Durable Object with Zero

The DO runs Zero's lower-level class API (not React hooks):

// src/worker/zero-do.ts
export class ZeroDO extends DurableObject {
  #z: Zero<Schema> = new Zero({
    server: "http://localhost:4848",
    userID: "anon",
    schema,
    kvStore: "mem",
  });

  constructor(state: DurableObjectState, env: Env) {
    super(state, env);
    const view = this.#z.materialize(queries.messages());
    view.addListener(this.#render);
  }

  #render = (messages) => {
    // Print live-updating table to console
  };
}

Cleaning Up

To stop Postgres and remove volumes:

npm run dev:db-down

To completely clean the database and Zero replica files:

npm run dev:clean

Deployment

This is a development demo. For production deployment to Cloudflare:

  1. Create a production Postgres database with logical replication enabled
  2. Set up environment variables in Cloudflare dashboard or via wrangler secret
  3. Deploy: npm run deploy

Note: You'll need to host the Zero cache server somewhere accessible to your Worker (Cloudflare Workers can make outbound HTTP requests).

Learn More

About

Simple demo of Zero on Cloudflare with Hono, React, and Durable Objects

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published