Documentation Index
Fetch the complete documentation index at: https://mintlify.com/denoland/celld/llms.txt
Use this file to discover all available pages before exploring further.
Celld implements the Workers JS RPC system: WorkerEntrypoint and RpcTarget from cloudflare:workers, named entrypoints on service bindings, and method calls on Durable Object stubs. Arguments and returns use structured clone. Values that cannot be cloned — functions, streams, RpcTarget instances — travel as stubs: the receiver gets a handle, and calling it runs the code back where it came from.
RPC methods on Durable Object stubs require either extends DurableObject in
the class declaration or the js_rpc compatibility flag in wrangler.jsonc.
Basic RPC
A Durable Object exposes RPC methods by extending DurableObject and calling super(state, env). The Worker calls those methods directly on the stub — no fetch(), no URL routing:
import { DurableObject } from "cloudflare:workers";
export class Account extends DurableObject {
constructor(state, env) {
super(state, env);
this.state = state;
}
async deposit(amount) {
const balance = ((await this.state.storage.get("balance")) ?? 0) + amount;
await this.state.storage.put("balance", balance);
return balance;
}
async balance() {
return (await this.state.storage.get("balance")) ?? 0;
}
// Cloneable in, cloneable out — the shape a cross-isolate call needs.
async depositMany(amounts) {
const running = [];
for (const amount of amounts) running.push(await this.deposit(amount));
return { total: running.at(-1), running };
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const account = env.ACCOUNT.getByName("acct-1");
switch (url.pathname) {
case "/deposit":
return Response.json({ balance: await account.deposit(10) });
case "/deposit-many":
return Response.json(await account.depositMany([1, 2, 3]));
default:
return Response.json({ balance: await account.balance() });
}
},
};
Arguments and returns
RPC arguments and return values are transferred using structured clone. Types that structured clone supports — plain objects, arrays, Map, Set, ArrayBuffer, Date — cross without issue.
Values that cannot be cloned travel as stubs:
| Value type | How it travels |
|---|
| A plain function | Becomes a callable stub |
A ReadableStream | Becomes a stream stub |
An RpcTarget subclass | Becomes an RpcTarget stub |
The receiver holds the stub for the lifetime of the RPC call. Dispose of stub resources with using to release the underlying handle promptly.
Named entrypoints
A WorkerEntrypoint is a second, separately addressable interface on the same Worker. Reach it in-process with ctx.exports, or from another project with a service binding by name:
import { WorkerEntrypoint, RpcTarget } from "cloudflare:workers";
class Receipt extends RpcTarget {
constructor(id, balance) {
super();
this.id = id;
this.balance = balance;
}
describe() {
return `${this.id} holds ${this.balance}`;
}
}
export class Ledger extends WorkerEntrypoint {
add(left, right) {
return left + right;
}
// A returned function becomes a stub — a callback factory.
adder(left) {
return (right) => left + right;
}
// A function argument arrives as a stub — the callee calls back into the caller.
async tally(values, onEach) {
let total = 0;
for (const value of values) {
total += value;
await onEach(value, total);
}
return total;
}
// Returning an RpcTarget lets the caller pipeline method calls on the result.
open(id, balance) {
return new Receipt(id, balance);
}
}
Promise pipelining
When a method returns an RpcTarget, the caller can pipeline a further method call on the result without waiting for the first call to resolve. Both hops leave together:
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
switch (url.pathname) {
// ctx.exports reaches Ledger in this same Worker (same isolate).
case "/entrypoint":
return Response.json({ sum: await ctx.exports.Ledger.add(20, 22) });
// adder() returns a function stub; calling it runs in the entrypoint.
case "/adder": {
using addFive = await ctx.exports.Ledger.adder(5);
return Response.json({ sum: await addFive(37) });
}
// tally() receives a callback stub; the entrypoint calls back into the Worker.
case "/callback": {
const steps = [];
const total = await ctx.exports.Ledger.tally([1, 2, 3], (v, running) => {
steps.push({ v, running });
});
return Response.json({ total, steps });
}
// open() returns an RpcTarget stub; `using` disposes it when done.
case "/receipt": {
using receipt = await ctx.exports.Ledger.open("acct-1", 100);
return Response.json({ describe: await receipt.describe() });
}
// Pipeline: no await on open(), so both hops travel together.
case "/pipeline": {
const describe = await ctx.exports.Ledger.open("acct-9", 7).describe();
return Response.json({ describe });
}
}
},
};
ctx.exports covers the entrypoints that the wrangler.jsonc configuration declares. It gives no stub for an undeclared Durable Object class.
Current limits
Stubs cannot cross isolate boundaries yet. A Durable Object is its own
isolate. If you pass a function to a DO method, or return an RpcTarget from
one, the call throws:RPC stubs cannot cross isolate boundaries yet
To use callbacks and RpcTarget pipelining, keep the call within the same
isolate by going through ctx.exports.
| Scenario | What works | What does not work |
|---|
| DO stub (cross-isolate) | Plain method calls with structured-cloneable args and returns | fetch(), awaitable properties, pipelined paths, function args, RpcTarget returns |
Named entrypoint via ctx.exports (same-isolate) | Full surface: method calls, callbacks, RpcTarget, pipelining | — |
| Named entrypoint via a cross-isolate service binding | Single method calls with structured-cloneable values | fetch(), awaitable properties, pipelined paths |
The wrangler.jsonc for the RPC example:
{
"name": "rpc",
"main": "index.js",
"compatibility_date": "2026-01-01",
"durable_objects": { "bindings": [{ "name": "ACCOUNT", "class_name": "Account" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Account"] }]
}