npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

claydo

v0.1.0

Published

One Durable Object class, many use cases. Route each instance to a fixed kind and stay under the namespace limit.

Readme

claydo

One Durable Object class, many use cases.

Register each use case as a kind. The library binds every Durable Object instance to one fixed kind, forever. You deploy one DO class, one binding, and one migration. You never touch migrations again when you add a kind.

import { union, kinds } from "claydo";

// One exported DO class hosts all kinds.
export class AppDO extends union({
  counter: Counter,
  chat: ChatRoom,
  billing: BillingAgent,
}) {}

// Typed access from your Worker.
const app = kinds(env.APP_DO);
await app.counter.get("user-42").increment(2);

Why

Cloudflare recommends one DO class per use case. Each class needs a binding and a migration. An account has a limit of 500 Durable Object namespaces. Teams with many services and many use cases reach this limit.

This library inverts the pattern. One DO class is the host. Your use cases are plain classes. The host loads the correct class for each instance at runtime. This is safe because the kind of an instance never changes:

  • Instance names carry the kind as a prefix: counter:user-42.
  • The host persists the kind in the instance storage on first contact.
  • A different kind can never attach to the same instance. The host rejects mismatched access with an error that names the instance and both kinds.

Because one instance always runs one kind, each kind owns the full SQLite database, alarms, and WebSockets of its instances. Kinds do not share instances, so they need no schema coordination and no cross-kind migrations.

Install

npm install claydo

Quickstart

1. Write kinds as plain Durable Object classes

A kind is any class with a (ctx, env) constructor. Extend DurableObject to get this.ctx and this.env:

// src/kinds.ts
import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    ctx.storage.sql.exec(
      `CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
    );
  }

  increment(by = 1): number {
    return this.ctx.storage.sql
      .exec<{ value: number }>(
        `INSERT INTO counters (name, value) VALUES ('default', ?)
         ON CONFLICT(name) DO UPDATE SET value = value + excluded.value
         RETURNING value`,
        by,
      )
      .one().value;
  }
}

export class ChatRoom extends DurableObject<Env> {
  async fetch(request: Request): Promise<Response> {
    const pair = new WebSocketPair();
    this.ctx.acceptWebSocket(pair[1]);
    return new Response(null, { status: 101, webSocket: pair[0] });
  }

  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
    for (const socket of this.ctx.getWebSockets()) socket.send(message);
  }
}

2. Export one host class

// src/index.ts
import { union } from "claydo";
import { Counter, ChatRoom } from "./kinds";

export class AppDO extends union({
  counter: Counter,
  chat: ChatRoom,
}) {}

union() validates the registry when the module loads: kind names must not contain : or start with __, and kind classes must not define methods named id, name, kind, or stub (the stub reserves those for metadata). Validation failures throw at startup, so wrangler deploy and local dev catch them before any traffic does.

3. Configure one binding and one migration

// wrangler.jsonc
{
  "durable_objects": {
    "bindings": [{ "name": "APP_DO", "class_name": "AppDO" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["AppDO"] }]
}

This is the only migration you will ever write. New kinds are code changes, not migrations.

4. Call kinds from your Worker

import { kinds } from "claydo";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const app = kinds(env.APP_DO);

    // RPC, fully typed from the registry.
    const value = await app.counter.get("user-42").increment();

    // fetch() and WebSockets forward to the kind implementation.
    if (request.headers.get("Upgrade") === "websocket") {
      return app.chat.get("lobby").fetch(request);
    }

    return Response.json({ value });
  },
} satisfies ExportedHandler<Env>;

TypeScript infers the kind names and the method signatures from the registry. kinds(env.APP_DO) only exposes registered kind names, and a typo produces a "Did you mean ...?" diagnostic. The stub only exposes the methods of the kind class, with awaited return types.

kind(env.APP_DO, "counter") is the two-argument equivalent. Use it when the kind name is a runtime value; type that value with KindNameOf:

import { kind, type KindNameOf } from "claydo";

const name = pickKind() as KindNameOf<typeof env.APP_DO>;
const accessor = kind(env.APP_DO, name);

Calling kinds from inside a kind

Kinds receive env, so cross-kind calls work the same inside a Durable Object as in a Worker. This is the pattern for coordination between use cases:

export class Cart extends DurableObject<Env> {
  async checkout(): Promise<void> {
    const inventory = kinds(this.env.APP_DO).inventory;
    await inventory.get(productId).reserve(qty);
  }
}

How the host resolves the kind

The host resolves the kind of an instance from three sources, in this order:

  1. Storage. The host persists the kind on first contact. Storage is the source of truth after that.
  2. The name prefix. app.counter.get("user-42") names the instance counter:user-42. The host reads the prefix from ctx.id.name.
  3. The call hint. The client helper sends the kind with every RPC call and with a x-claydo-kind header on every fetch(). The hint initializes instances reached through get() and unique(). fromId() sends the hint for validation only and never initializes an instance.

If a caller expects one kind and the instance has another, the call fails with an error that names the instance and both kinds. An instance never changes its kind.

Identity

  • get(name) maps to the Durable Object name <kind>:<name>. Equal names under different kinds map to different instances. Logical names may themselves contain :; only the first segment routes, and only when it matches a registered kind.
  • unique() creates a newUniqueId() instance. The first call pins the kind. Store stub.id.toString() to reach it again with fromId().
  • fromId(id) reaches an existing instance. It never initializes: if the instance has no kind yet, calls fail and tell you to create the instance with get() or unique() first.
  • instanceName(this.ctx) returns the logical name without the kind prefix, from inside a kind implementation. It is safe everywhere in a kind, including its constructor, because kinds construct lazily on first contact. It returns undefined for unique-ID instances.
  • Do not mix helper access with raw namespace access. getByName("room-1") reaches a different instance than app.chat.get("room-1") (which maps to chat:room-1). If you fetch such an unprefixed instance, the host answers 400 with an explanation of this exact mistake.

Error propagation

When a kind method throws, the stub rethrows an Error to the caller with:

  • the original name and message;
  • the original stack, pointing into your kind code, followed by a marker line at [remote call <kind>.<method>() via claydo] and the local frames;
  • all own enumerable fields of the error that survive structured clone (for example error.code or error.productId).

What does not survive: the prototype. instanceof MyError is false after the hop — match on error.name instead. Non-cloneable fields are dropped.

Errors thrown in alarm() and webSocket* handlers have no caller to reach. The host logs them with console.error, including the kind and the instance identity, then rethrows so the runtime semantics (such as alarm retries) stay intact.

Serialization rules

RPC arguments and return values travel over Workers RPC:

  • Structured-cloneable values work: plain objects, arrays, strings, numbers, Map, Set, Date, ArrayBuffer, typed arrays.
  • Functions and RpcTarget instances become live RPC stubs (a Workers RPC feature — be deliberate about returning them).
  • Custom class instances do not serialize. The call fails and the stub wraps the failure with context: claydo: call to <kind>.<method>() failed: Could not serialize object .... Return plain objects instead.

Storage lifecycle

ctx.storage.deleteAll() inside a kind also deletes the kind marker the library persists. Named instances re-pin from the name prefix, but unique-ID instances become kind-less husks. Use the provided helper instead:

import { resetStorage } from "claydo";

async destroy(): Promise<void> {
  await resetStorage(this.ctx); // deleteAll, but the kind stays pinned
  await this.ctx.storage.deleteAlarm();
}

Durable Object instances cannot be deleted, only emptied; any later access revives them. Design "delete" flows as resetStorage() plus removal of the id from wherever you track instances.

Third-party Durable Object libraries

A kind is any class with a (ctx, env) constructor. Durable Object framework classes match this shape. Register them directly:

import { Server, type Connection, type WSMessage } from "partyserver";
import { union, kinds } from "claydo";

class GameRoom extends Server<Env> {
  onMessage(connection: Connection, message: WSMessage): void {
    this.broadcast(message);
  }
}

export class AppDO extends union({
  counter: Counter,
  game: GameRoom, // PartyServer, injected as a kind
}) {}

// In the Worker:
return kinds(env.APP_DO).game.get("match-1").fetch(request);

What works, and what to know (verified against [email protected]):

  • Server lifecycle hooks, broadcast, hibernation, and onAlarm work. The integration test suite runs a real Server as a kind.
  • this.name inside the Server is the full instance name, including the kind prefix (for example game:match-1), because PartyServer reads ctx.id.name. Use instanceName(this.ctx) when you need the logical name.
  • getServerByName() is not supported: it calls a setName RPC method on the stub, and the host does not expose arbitrary RPC methods. Use kinds(env.APP_DO).game.get(name) instead — it serves the same purpose.
  • routePartykitRequest() routes by URL to a binding and passes the room name without a kind prefix, so it reaches unprefixed instances. Route manually instead:
// PartyKit-style URLs: /parties/:party/:room
const match = /^\/parties\/([^/]+)\/([^/]+)$/.exec(url.pathname);
if (match) {
  return kinds(env.APP_DO).game.get(match[2]).fetch(request);
}

API

union(kinds)

Creates the host Durable Object class. kinds maps kind names to classes. Export the returned class and point your binding and migration at it. Validates kind names and reserved method names at module load.

kinds(namespace) / kind(namespace, kindName)

kinds() returns one typed accessor per registered kind, as properties. kind() returns a single accessor; use it with runtime kind names typed as KindNameOf<typeof namespace>. Each accessor:

| Method | Description | | --- | --- | | get(name, options?) | Stub for the named instance (<kind>:<name>). Initializes on first contact. | | unique(options?) | Stub for a new newUniqueId() instance. Initializes on first call. | | fromId(id) | Stub from a stored ID string or DurableObjectId. Never initializes. | | idFromName(name) | The DurableObjectId that get(name) resolves to. |

Each stub exposes the public methods of the kind class as async functions, plus:

| Property | Description | | --- | --- | | fetch(input, init?) | Sends a request to the kind's fetch() handler. | | id | The DurableObjectId. | | name | The logical name, when created with get(name); otherwise undefined. | | kind | The kind name. | | stub | The raw DurableObjectStub, as an escape hatch (tests, runDurableObjectAlarm). |

instanceName(ctx)

Returns the logical instance name without the <kind>: prefix, or undefined for unique-ID instances. Safe anywhere in a kind, including the constructor.

resetStorage(ctx)

deleteAll() that re-pins the kind marker. Use it instead of a raw ctx.storage.deleteAll() inside kinds.

Forwarded handlers

The host forwards these handlers to the kind implementation when the kind defines them: fetch, alarm, webSocketMessage, webSocketClose, webSocketError. Hibernated WebSockets and alarms wake the correct kind, because the host reads the persisted kind from storage.

Rules and limits

  • RPC covers methods only. The stub does not proxy property access. Calling a plain property through the stub fails with a message that names the property and its type; add a getter method instead.
  • Reserved names. Kind classes must not define methods named id, name, kind, or stubunion() rejects them at startup. Getters with those names are fine. Method names starting with __ are not callable through the stub.
  • One namespace, one billing and metrics bucket. All kinds share the DO namespace, so per-kind analytics need your own labels.
  • Kind renames are breaking. The kind name is part of the instance name and of the persisted state. Renamed kinds fail loudly for initialized instances (unknown kind) but get(name) under the new name reaches fresh, empty instances. Treat kind names as permanent identifiers.
  • Always go through the helpers. Raw namespace access without the <kind>: prefix reaches different instances (see Identity).

Testing

The repository tests run inside the Workers runtime with @cloudflare/vitest-pool-workers:

npm install
npm test           # library test suite
npm run test:examples  # the 8 example apps under examples/

Tips that apply to your own tests:

  • runDurableObjectAlarm and runInDurableObject from cloudflare:test expect a raw DurableObjectStub. Pass stub.stub (the escape hatch) or a raw env.APP_DO.get(...) stub.
  • Isolated storage is per test file; tests within one file share DO state. Use distinct instance names per test.

The examples/ folder contains eight complete applications (chat rooms with rate limiting, collaborative documents, an alarm scheduler, instance management, a Lunora-style live table, a token-bucket rate limiter, a game lobby, and a shop with cross-kind checkout), each with tests and a DX audit report.

License

MIT