// 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.