Cloudflare Durable Objects ile Stateful Edge Uygulama Geliştirme
Edge’de state tutmak her zaman bir baş ağrısıydı. Klasik yaklaşımda ya merkezi bir veritabanına dönerdiniz, ya Redis’e, ya da bazı zekice cache hileleri yapardınız. Cloudflare Durable Objects bu denklemi değiştiriyor: state’i doğrudan edge’de, tutarlı ve global olarak erişilebilir şekilde tutmanıza izin veriyor. Ben bu teknolojiyi gerçek bir production sistemde yaklaşık sekiz aydır kullanıyorum ve size hem güzelliklerini hem de sizi şaşırtacak köşelerini anlatacağım.
Durable Objects Nedir, Neden Farklıdır?
Cloudflare Workers, her istek için stateless çalışır. İki farklı isteğin aynı Worker instance’ına denk geleceğinin garantisi yoktur. Bu durum çoğu use case için sorun değildir, ama gerçek zamanlı uygulamalar, oyunlar, collaborative editörler veya rate limiting gibi senaryolarda bir objenin state’ini birden fazla istek arasında paylaşmanız gerekir.
Durable Objects tam burada devreye girer. Her Durable Object’in dünyada tek bir instance‘ı çalışır. Bu instance bir ID ile adreslenebilir ve tüm istekler o ID’ye yönlendirildiğinde, guaranteed olarak aynı JavaScript nesnesine ulaşırsınız. Bu, distributed sistemlerdeki “single writer” prensibinin edge dünyasına taşınmasıdır.
Bunu Redis ile karıştırmayın. Redis ayrı bir servis, network hop var, consistency garantileri farklı. Durable Object ise compute ve storage’ı bir arada tutar; state’i okuduğunuzda aynı süreçten okuyorsunuz.
Geliştirme Ortamını Kurmak
Önce wrangler CLI ile başlayalım. Wrangler 3.x kullanmanızı tavsiye ederim, eski sürümlerde Durable Objects desteği biraz pürüzlüydü.
npm install -g wrangler
wrangler login
mkdir durable-objects-demo
cd durable-objects-demo
wrangler init --yes
wrangler.toml dosyanızı şöyle yapılandırın:
name = "durable-objects-demo"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]
Migrations bloğu kritik. Durable Object sınıfı oluşturduğunuzda veya sildiğinizde bunu Cloudflare’e bildirmeniz gerekiyor, aksi halde deployment başarısız olur. Bu kuralı ilk hafta birkaç kez öğrendim.
İlk Durable Object: Basit Sayaç
Kavramı oturtmak için klasik sayaç örneğiyle başlayalım ama üzerine biraz daha gerçekçi bir şey koyacağız:
// src/counter.ts
export class Counter {
private state: DurableObjectState;
private count: number = 0;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
// blockConcurrencyWhile ile constructor async işlemlerini tamamlayabilirsiniz
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<number>("count");
this.count = stored ?? 0;
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/increment") {
this.count++;
await this.state.storage.put("count", this.count);
return new Response(JSON.stringify({ count: this.count }), {
headers: { "Content-Type": "application/json" }
});
}
if (url.pathname === "/value") {
return new Response(JSON.stringify({ count: this.count }), {
headers: { "Content-Type": "application/json" }
});
}
return new Response("Not Found", { status: 404 });
}
}
blockConcurrencyWhile metodunu atlamayın. Constructor çalışırken ilk istek gelirse ve siz henüz storage’dan okumadıysanız count değeri sıfır olarak dönecek. Bu ince bug’ı production’da fark etmek zevkli olmaz.
WebSocket ile Gerçek Zamanlı Chat Odası
Asıl güç, Durable Objects’in WebSocket’lerle entegrasyonunda ortaya çıkıyor. Şimdi gerçek bir kullanım senaryosu yazalım: birden fazla kullanıcının katılabildiği bir chat odası.
// src/chat-room.ts
export class ChatRoom {
private state: DurableObjectState;
private sessions: Map<WebSocket, { username: string }> = new Map();
private messages: Array<{ user: string; text: string; timestamp: number }> = [];
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.state.getWebSockets().forEach((ws) => {
const meta = ws.deserializeAttachment() as { username: string };
this.sessions.set(ws, meta);
});
}
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get("Upgrade");
if (!upgradeHeader || upgradeHeader !== "websocket") {
return new Response("WebSocket bekleniyor", { status: 426 });
}
const url = new URL(request.url);
const username = url.searchParams.get("username") ?? "Anonim";
const [client, server] = Object.values(new WebSocketPair()) as [WebSocket, WebSocket];
this.state.acceptWebSocket(server);
server.serializeAttachment({ username });
this.sessions.set(server, { username });
// Son 50 mesajı yeni kullanıcıya gönder
const history = await this.state.storage.get<typeof this.messages>("history") ?? [];
client.send(JSON.stringify({ type: "history", messages: history.slice(-50) }));
this.broadcast({ type: "join", user: username, timestamp: Date.now() }, server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const session = this.sessions.get(ws);
if (!session) return;
const data = JSON.parse(message as string);
const entry = {
user: session.username,
text: data.text,
timestamp: Date.now()
};
// Mesajı kalıcı olarak sakla
const history = await this.state.storage.get<typeof this.messages>("history") ?? [];
history.push(entry);
if (history.length > 500) history.shift(); // Max 500 mesaj tut
await this.state.storage.put("history", history);
this.broadcast({ type: "message", ...entry });
}
async webSocketClose(ws: WebSocket, code: number, reason: string) {
const session = this.sessions.get(ws);
if (session) {
this.sessions.delete(ws);
this.broadcast({ type: "leave", user: session.username, timestamp: Date.now() });
}
}
private broadcast(message: object, exclude?: WebSocket) {
const payload = JSON.stringify(message);
for (const [ws] of this.sessions) {
if (ws !== exclude && ws.readyState === WebSocket.READY_STATE_OPEN) {
ws.send(payload);
}
}
}
}
Bu örnekte state.getWebSockets() ve serializeAttachment metodlarına dikkat edin. Durable Object belleği sıfırlandığında (bu olabilir, buna hazır olun) WebSocket bağlantıları korunuyor ama sizin in-memory state’iniz gidiyor. serializeAttachment ile WebSocket’e metadata bağlarsanız, constructor’da bu metadata’yı geri okuyabilirsiniz.
Worker Tarafında Routing
Durable Object’e nasıl ulaşacaksınız? Worker tarafında ID üretimi ve routing yapmanız gerekiyor:
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith("/chat/")) {
const roomName = url.pathname.split("/")[2];
if (!roomName) {
return new Response("Oda adı gerekli", { status: 400 });
}
// İsim bazlı ID: aynı isim her zaman aynı instance'a gider
const id = env.CHAT_ROOM.idFromName(roomName);
const stub = env.CHAT_ROOM.get(id);
return stub.fetch(request);
}
if (url.pathname.startsWith("/counter/")) {
const counterId = url.pathname.split("/")[2];
// Rastgele ID: her seferinde yeni instance
// const id = env.COUNTER.newUniqueId();
// İsim bazlı ID kullanmak genellikle daha mantıklı
const id = env.COUNTER.idFromName(counterId);
const stub = env.COUNTER.get(id);
return stub.fetch(request);
}
return new Response("Merhaba Edge!", { status: 200 });
}
};
idFromName ile newUniqueId arasındaki fark önemli. idFromName deterministik; “odasi-1” her zaman aynı ID’yi üretir. newUniqueId ise her çağrıda farklı ID üretir ve o ID’yi bir yerde saklamazsanız o instance’a bir daha ulaşamazsınız.
Rate Limiting Gerçek Dünya Senaryosu
Chat odasının ötesine geçelim. Production’da en çok kullandığım pattern: IP bazlı rate limiting. Merkezi bir Redis olmadan, edge’de, tutarlı rate limiting.
// src/rate-limiter.ts
interface RateLimitState {
requests: number[];
blocked: boolean;
blockedUntil?: number;
}
export class RateLimiter {
private state: DurableObjectState;
private windowMs = 60_000; // 1 dakika
private maxRequests = 100;
private blockDurationMs = 300_000; // 5 dakika blok
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const now = Date.now();
const data = await this.state.storage.get<RateLimitState>("state") ?? {
requests: [],
blocked: false
};
// Blok süresi bitti mi?
if (data.blocked && data.blockedUntil && now > data.blockedUntil) {
data.blocked = false;
data.requests = [];
}
if (data.blocked) {
return new Response(JSON.stringify({
allowed: false,
retryAfter: Math.ceil(((data.blockedUntil ?? now) - now) / 1000)
}), {
status: 429,
headers: { "Content-Type": "application/json" }
});
}
// Pencere dışındaki istekleri temizle
data.requests = data.requests.filter(ts => now - ts < this.windowMs);
data.requests.push(now);
if (data.requests.length > this.maxRequests) {
data.blocked = true;
data.blockedUntil = now + this.blockDurationMs;
await this.state.storage.put("state", data);
return new Response(JSON.stringify({ allowed: false, retryAfter: 300 }), {
status: 429,
headers: { "Content-Type": "application/json" }
});
}
await this.state.storage.put("state", data);
return new Response(JSON.stringify({
allowed: true,
remaining: this.maxRequests - data.requests.length
}), {
headers: { "Content-Type": "application/json" }
});
}
}
Bu rate limiter’ı Worker’dan şöyle kullanırsınız:
// Worker içinde rate limit kontrolü
async function checkRateLimit(request: Request, env: Env): Promise<boolean> {
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const id = env.RATE_LIMITER.idFromName(`ip:${ip}`);
const stub = env.RATE_LIMITER.get(id);
const response = await stub.fetch(new Request("https://internal/check", {
method: "POST"
}));
const result = await response.json<{ allowed: boolean }>();
return result.allowed;
}
Gerçek dünyadan bir not: IP bazlı rate limiting için Durable Objects mükemmel çalışıyor ama çok sayıda farklı IP’niz varsa her IP için ayrı bir Object instance’ı oluşturursunuz. Bu tamamen normal ve Cloudflare’in tasarımına uygun, ama bunu bilerek gitmeniz lazım.
Storage API’nin İncelikleri
Durable Objects storage’ı aslında oldukça yetenekli bir key-value store. Bazı operasyonlar özellikle dikkat ister:
// Toplu okuma - tek seferde birden fazla key
const values = await this.state.storage.get<Map<string, number>>(
["user:1:score", "user:2:score", "user:3:score"]
);
// Prefix ile listeleme - sıralı döner
const userEntries = await this.state.storage.list<string>({
prefix: "user:",
limit: 100,
reverse: false
});
// Transaction benzeri atomic işlemler
await this.state.storage.transaction(async (txn) => {
const balance = await txn.get<number>("balance") ?? 0;
if (balance < 100) {
throw new Error("Yetersiz bakiye");
}
await txn.put("balance", balance - 100);
await txn.put("last_transaction", Date.now());
});
// Alarm kurma - scheduled işlemler için
await this.state.storage.setAlarm(Date.now() + 30_000); // 30 saniye sonra
// Alarm handler
async alarm() {
console.log("Alarm çalıştı, temizlik yapılıyor...");
await this.cleanup();
// Bir sonraki alarmı kur
await this.state.storage.setAlarm(Date.now() + 60_000);
}
Alarm özelliği genellikle göz ardı edilir ama production’da çok işe yarıyor. Örneğin bir chat odasında 30 dakika aktivite yoksa kaynakları temizlemek, süresi dolmuş session’ları silmek veya periyodik özet emailler göndermek için kullanabilirsiniz.
Performans ve Sınırlamaları Anlamak
Durable Objects’i kullanmadan önce şunları kafanıza kazımanız lazım:
- Colocate edilme garantisi yok başlangıçta: İlk request’te Object en yakın datacenter’a yerleşir ama siz San Francisco’dansanız ve kullanıcılarınız İstanbul’daysa, Object San Francisco’da olabilir. Bu latency demektir.
- Single-threaded: Her Object single-threaded JavaScript çalıştırır. CPU-intensive işler yaparsanız diğer istekler beklemek zorunda kalır.
- Storage sınırları: Tek bir key için maximum 128KB. Büyük veri saklamak için Cloudflare R2 veya KV ile kombine kullanın.
- Soğuk başlangıç maliyeti: Object uzun süre kullanılmadıktan sonra ilk istek biraz yavaş gelebilir, bellek sıfırlandığı için.
- Fiyatlandırma: Her aktif Object-saat ücretlendirilir. Binlerce idle Object tutarsanız fatura sürpriz yapabilir.
Bir production senaryosunda bunu fark ettim: kullanıcı bazlı state tutan Objects’te “bölge optimizasyonu” yapabilirsiniz. Kullanıcının lokasyonunu biliyorsanız, o lokasyona yakın jurisdiction ile Object oluşturabilirsiniz:
// Lokasyon bazlı Object yerleşimi
const locationHint = getLocationHint(request); // "eeur", "weur", "apac" vb.
const id = env.USER_STATE.idFromName(`user:${userId}`);
const stub = env.USER_STATE.get(id, {
locationHint: locationHint
});
Bu özellik şu an sınırlı lokasyonları destekliyor ama Avrupa kullanıcıları için “eeur” seçmek ciddi latency farkı yaratıyor.
Local Geliştirme ve Test
Wrangler ile local development oldukça iyi çalışıyor:
# Local sunucu başlat
wrangler dev
# Miniflare ile daha izole test ortamı
npm install -D miniflare
# Test dosyası için
# vitest + miniflare entegrasyonu
npx wrangler dev --local --persist
--persist flag’i önemli: varsayılan olarak her wrangler dev çalıştırdığınızda storage temizlenir. --persist ile lokal dosya sisteminde saklanır. Geliştirirken state’in korunmasını istiyorsanız bunu kullanın.
Unit testler için Miniflare’i programmatik olarak kullanabilirsiniz:
// test/counter.test.ts
import { Miniflare } from "miniflare";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
describe("Counter Durable Object", () => {
let mf: Miniflare;
beforeAll(async () => {
mf = new Miniflare({
script: `
export class Counter {
constructor(state) { this.state = state; }
async fetch(req) {
const count = (await this.state.storage.get("count")) ?? 0;
await this.state.storage.put("count", count + 1);
return new Response(String(count + 1));
}
}
export default { fetch: () => new Response("ok") }
`,
durableObjects: { COUNTER: "Counter" },
});
});
afterAll(async () => await mf.dispose());
it("sayacı artırmalı", async () => {
const id = await mf.getDurableObjectId("COUNTER", "test");
const stub = await mf.getDurableObjectStorage(id);
// Test mantığı buraya
expect(stub).toBeDefined();
});
});
Deployment ve Monitoring
Deploy etmek basit ama migration’ları atlamamak lazım:
# Deploy öncesi type check
npx tsc --noEmit
# Staging'e deploy
wrangler deploy --env staging
# Production'a deploy
wrangler deploy --env production
# Durable Object storage'ını incelemek için
wrangler durable-objects list CHAT_ROOM
Monitoring tarafında Cloudflare Dashboard yeterli başlangıç noktası sağlıyor ama production’da kendi loglarınızı Workers Analytics Engine’e yazmanızı öneririm:
// Analytics Engine'e custom event yaz
async logEvent(env: Env, eventType: string, data: Record<string, unknown>) {
env.ANALYTICS.writeDataPoint({
blobs: [eventType, JSON.stringify(data)],
doubles: [Date.now()],
indexes: [eventType]
});
}
Sonuç
Durable Objects, edge computing’in “stateless olmak zorunda” kısıtlamasını kıran, gerçekten pratik bir teknoloji. Chat uygulamaları, collaborative araçlar, oyun state yönetimi, rate limiting ve session yönetimi gibi use case’lerde merkezi bir veritabanına ihtiyaç duymadan edge’de tutarlı state tutabiliyorsunuz.
Ancak her araç gibi, doğru problem için doğru araç. Büyük veri kümeleri üzerinde karmaşık sorgular yapmanız gerekiyorsa Durable Objects doğru yer değil. Kullanıcı başına küçük ama kritik state’ler, gerçek zamanlı koordinasyon gerektiren senaryolar ve global consistency’nin önemli olduğu durumlar için biçilmiş kaftan.
Production’da sekiz ay sonra söyleyebileceğim: migration yönetimine dikkat edin, Object başına storage boyutunu takip edin, alarm özelliğini mutlaka kullanın ve lokasyon hint’lerini ihmal etmeyin. Bunları yaparsanız Durable Objects gerçekten güvenilir ve performanslı bir platform sunuyor.
