Durable objects

Export a class as an object with one live instance per id, calls to it serialised one at a time, and state in the app's database.

Docs menu
// functions/rooms.js
export class Room {
  constructor(state, env) { this.state = state; this.members = 0; }
  async join(name) {
    const seen = (await this.state.storage.get("seen")) ?? [];
    await this.state.storage.put("seen", [...seen, name]);
    return ++this.members;
  }
  async fetch(request) { return new Response("room " + request.method); }
  async alarm() { /* a stored alarm is due */ }
}
export default {
  async fetch(request) {
    const rooms = substrio.objects.Room;
    const room = rooms.get(rooms.idFromName("lobby"));
    return Response.json({ members: await room.join("kris") });
  },
};

Classes and ids

Every named export of a function that is a class is the object class of that name (a function's prototype is assignable, a class's is not; the default export is never a class of its own). The names are read from the evaluated module, not from its source. Two functions of one app exporting the same class name: the first to load owns it and the second fails to load with an error naming the class and both functions (a deploy cannot see it, because a deploy does not run the module). A module may have no default export at all.

substrio.objects.Name has idFromName(text) (the same text always names the same object; the id is the first 16 bytes of sha256 of the class and the text), newUniqueId(), idFromString(hex) and get(id), which answers a stub. stub.fetch(request) calls instance.fetch(request); any other member call, stub.join("a"), calls that method with the arguments and answers its result. Arguments and results keep Date, BigInt, Map, Set, ArrayBuffer and typed arrays; a value with a cycle or a function throws a TypeError. A method that throws fails the call with its message. Request and response bodies are raw bytes.

An app never reaches another app's objects: the app is part of the object's identity and comes from the calling isolate, never from anything code sets.

One at a time

Each object has a mailbox. A call waits until the turn before it has settled (its promise resolved and its storage committed). Awaits inside a turn do not let another call in. Exactly one instance of an object exists at a time, and a turn that awaits (fetch, another object) does not hold up other objects.

A call that finds 10,000 calls already waiting on the mailbox is refused with 429. An object that calls itself through state.objects is refused at once; a call through the global substrio.objects is not recognised as a self-call, so a self-call or a cycle that way waits until the call's wall limit ends that turn.

state.blockConcurrencyWhile(fn) runs fn and holds every other call on the mailbox until it settles, for setup that must finish before any other turn reads the object's fields. state.waitUntil(promise) is awaited before the turn's reply is released, the same as ctx.waitUntil in concurrency.md.

Storage

state.storage: get(key | keys), put(key, value | entries), delete(key | keys), deleteAll(), list({ start, end, prefix, limit, reverse }) answering a Map in key order, transaction(fn) (an error, or txn.rollback(), discards its writes), getAlarm(), setAlarm(time), deleteAlarm().

A turn reads its own writes. Its writes and its alarm are applied together when the turn ends, before the reply is released: after a crash all of the turn is there or none of it.

Alarms

setAlarm(ms since the epoch | Date) stores one alarm per object. When it is due the object gets a turn calling alarm({ retryCount, isRetry }). The alarm survives a restart and, when overdue, fires at once. A failing alarm() is retried with doubling delays, 6 times starting at 1,000 ms, then dropped.

WebSockets

A client opens GET /v1/_objects/ws?class=Room&name=lobby (or &id=<32 hex digits> in place of name) as an upgrade, with the app's credentials as for any route. The object's fetch(request) gets the upgrade request with request.webSocket; it takes the socket with state.acceptWebSocket(request.webSocket) (a socket it does not accept, or a fetch that throws, is closed with 1008 or 1011). From then on every frame the client sends is a turn: webSocketMessage(ws, message) (a string, or an ArrayBuffer for a binary frame), in arrival order and one at a time with every other call of the object; the client's close is webSocketClose(ws, code, reason, wasClean). ws.send(data) and ws.close(code, reason) answer the client; state.getWebSockets() lists the accepted ones. A slow reader is never dropped silently: a socket whose unsent backlog passes 64 MB is closed with code 4290 after the frames queued before it, and a client the mailbox refuses is closed with 1013. An eviction does not close the sockets: the next frame builds the instance again over its stored state. A server restart ends every socket; the client opens one again.

Eviction

An object with no turn for 30 s loses its in-memory instance (its fields); the next call runs the constructor again over the stored state. A redeploy or a recycled isolate does the same.