Durable objects

Export a class as a durable object. Each id has one live instance, calls to that instance are serialized, and its state is stored 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

Each named class export of a function defines an object class with that name. A function's prototype is assignable, while a class's is not. The default export does not define an object class. Names are read from the evaluated module. If two functions in an app export the same class name, the first to load registers it and the second fails to load. The error names the class and both functions. Deployment cannot detect this because it does not run the module. A module may have no default export.

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 returns a stub. stub.fetch(request) calls instance.fetch(request); any other member call, stub.join("a"), calls that method with the arguments and returns its result. Arguments and results preserve 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 cannot access another app's objects. The calling isolate supplies the app as part of the object's identity; code cannot set it.

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 provides get(key | keys), put(key, value | entries), delete(key | keys), deleteAll(), list({ start, end, prefix, limit, reverse }), transaction(fn), getAlarm(), setAlarm(time), and deleteAlarm(). list returns a Map in key order. An error or txn.rollback() discards a transaction's writes.

A turn reads its writes. Its writes and alarm are applied together when the turn ends, before the reply is released. The commit includes all changes together.

Alarms

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

WebSockets

A client upgrades GET /v1/_objects/ws?class=Room&name=lobby using the app's credentials. Use &id=<32 hex digits> instead of name to select by id. The object's fetch(request) receives the upgrade request with request.webSocket and accepts it with state.acceptWebSocket(request.webSocket). An unaccepted socket or a throwing fetch closes with 1008 or 1011.

Each client frame becomes a turn calling webSocketMessage(ws, message), with a string or an ArrayBuffer for a binary frame. Frames run in arrival order and one at a time with other object calls. A client close calls webSocketClose(ws, code, reason, wasClean). Use ws.send(data) and ws.close(code, reason) to respond, and state.getWebSockets() to list accepted sockets. A socket whose unsent backlog exceeds 64 MB closes with code 4290 after previously queued frames. A client refused by the mailbox closes with 1013. Eviction does not close sockets; the next frame creates the instance again over stored state. A server restart closes all sockets, and the client must reconnect.

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.