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 supports both inbound hibernatable WebSockets and outbound WebSocket clients from Durable Objects. Inbound sockets hibernate the cell between messages to minimize memory cost — the cell wakes only when a message arrives. Outbound sockets let a cell connect to an upstream WebSocket server.
Inbound hibernatable WebSockets
To accept an inbound WebSocket, create a WebSocketPair in the fetch handler, call state.acceptWebSocket(server) to hand the server side to the runtime, and return the client side in the response. The cell then handles messages through dedicated handlers:
export class W {
constructor(state, env) { this.state = state; }
async fetch(request) {
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
return new Response("websocket upgrade required", { status: 426 });
}
const pair = new WebSocketPair();
const server = pair[0];
this.state.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: pair[1] });
}
async webSocketMessage(ws, msg) {
let count = (await this.state.storage.get("count")) ?? 0;
count++;
await this.state.storage.put("count", count);
ws.send(JSON.stringify({ echo: msg, count }));
}
}
export default {
async fetch(request, env) {
return env.W.get(env.W.idFromName("w")).fetch(request);
}
};
The wrangler.jsonc for this project:
{
"name": "wsecho",
"main": "index.js",
"compatibility_date": "2026-01-01",
"durable_objects": { "bindings": [{ "name": "W", "class_name": "W" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["W"] }]
}
WebSocket handlers you can implement on the class:
| Handler | Signature | Fires when |
|---|
webSocketMessage | (ws, message) | A text or binary message arrives |
webSocketClose | (ws, code, reason, wasClean) | The client closes the connection |
webSocketError | (ws, error) | An error occurs on the socket |
A hibernated cell keeps its WebSocket clients connected. The cell has been removed from memory, but when the next message arrives celld restores it from the bucket, runs the constructor, and calls the handler. The client never sees a disconnect.
Attachments
You can attach structured-clone data to an accepted WebSocket. The attachment travels with the socket and is available to handlers after the cell hibernates and wakes:
this.state.acceptWebSocket(server, ["tag-a", "tag-b"]);
The second argument is the attachment. It holds anything that structured clone accepts — strings, numbers, plain objects, arrays.
Auto-response
setWebSocketAutoResponse registers a message pattern and a reply. When a matching message arrives, celld answers it without waking the cell:
Auto-response is useful for ping/pong keepalives. The matching message is
answered immediately without activating the cell, which keeps memory cost near
zero for idle connections.
async fetch(request) {
// ...upgrade...
this.state.acceptWebSocket(server);
this.state.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong")
);
return new Response(null, { status: 101, webSocket: pair[1] });
}
Outbound WebSocket clients
A Durable Object can open an outbound WebSocket connection with the WebSocket constructor. The following example connects to a target URL, sends a message, stores the reply, and closes:
export class Client {
constructor(state) {
this.state = state;
}
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/connect") {
const input = request.method === "POST" ? await request.json() : {};
const target = input.url ?? url.searchParams.get("url");
if (!target) return new Response("url required", { status: 400 });
const protocol = input.protocol ?? url.searchParams.get("protocol");
const socket = new WebSocket(target, protocol ? [protocol] : []);
const message = input.message ?? url.searchParams.get("message");
socket.addEventListener("open", () => {
if (Array.isArray(input.binary)) socket.send(new Uint8Array(input.binary));
else if (message) socket.send(message);
});
socket.addEventListener("message", async (event) => {
const binary = event.data instanceof ArrayBuffer;
await this.state.storage.put(
"message",
binary ? Array.from(new Uint8Array(event.data)) : event.data,
);
await this.state.storage.put("binary", binary);
socket.close(1000, "done");
});
socket.addEventListener("error", async () => {
await this.state.storage.put("error", true);
});
return new Response(JSON.stringify({ connecting: true }), { status: 202 });
}
return new Response(JSON.stringify({
message: await this.state.storage.get("message"),
binary: await this.state.storage.get("binary") ?? false,
error: await this.state.storage.get("error") ?? false,
}));
}
}
export default {
async fetch(request, env) {
return env.CLIENT.get(env.CLIENT.idFromName("client")).fetch(request);
},
};
An open outbound socket keeps the cell resident. The cell will not hibernate while the connection is live.
Outbound WebSockets do not survive cell migration. If a cell moves to
another node — for example, during a rolling restart — the outbound
connection does not continue. To make the connection intent durable, store
the target URL and any relevant state in state.storage before connecting,
then reconnect in the constructor or at the start of the next fetch call.
Limitations
getTags() is not available. Use attachments to store metadata with a socket.
- Each node limits how much residency outbound sockets can hold.
- Cross-node WebSocket routing works, but test coverage is thinner for specific close codes and reconnection scenarios.