Örnek: mesajlaşma (Cloudflare)
examples/messaging: Cellonay tarzı bir OTP + WhatsApp servisi. Cloudflare Worker, hesap başına bir Durable Object (ledger, bakiye, sayaçlar), D1 (usage), KV (OTP kayıtları), Queue (geç yayımlanan fiyatlar) ve aylık cron. Prepaid.
bunx vitest run examples/messaging # workerd'siz testler (DurableObject shim'i)cd examples/messaging && bun run dev # wrangler dev| Dosya | Rolü |
|---|---|
src/catalog.ts |
Paylaşılan katalog; adapter yok |
src/prices.ts |
Katmanlı fiyat tablosu + rota maliyeti yedeği |
src/app.ts |
Worker mantığı: OTP gönder / tekrar / doğrula / süresi dolsun, WhatsApp penceresi, aylık ücret |
src/account.ts |
Hesap DO’sunun mantığı (AccountCore), DurableObject sınıfından bağımsız |
src/do.ts, src/worker.ts |
Cloudflare’in örneklediği ince sarmalayıcılar |
Katalog
Bölüm başlığı “Katalog”import { buildMetering, type Metering, type PricedLineOf, type RefOf } from "pricemeter";import { z } from "zod";
export const Context = z.object({ accountId: z.string(), plan: z.enum(["free", "pro", "enterprise"]),});export type Context = z.infer<typeof Context>;
const country = z.string().length(2);
export const catalog = buildMetering() .context(Context) .refs(["otp_send", "wa_window", "month"]) // pool first: every message, whatever the channel, counts toward the monthly free platform quota .meter("msg/free_pool") .meter("otp/sms", { country }, { feeds: { "msg/free_pool": 1 } }) .meter("otp/whatsapp", { country }, { feeds: { "msg/free_pool": 1 } }) .meter("otp/verify") .meter("wa/window", { country }) .meter("ip/dedicated");
export type Catalog = typeof catalog;type State = Catalog extends Metering<infer S> ? S : never;/** A priced line of this catalog, as `plan.observe(...).lines` returns it. */export type CatalogLine = PricedLineOf<State>;export type CatalogRef = RefOf<State>;Havuz (msg/free_pool) önce tanımlanır; SMS ve WhatsApp onu 1 ağırlıkla besler, böylece kanaldan bağımsız aylık 1.000 ücretsiz mesaj kotası (B4) tek bir kademeli tarifedir. Katalog adapter’sızdır: Worker bind eder, DO yalnızca price() için kullanır.
Fiyatlar
Bölüm başlığı “Fiyatlar”export const PRICE_ROWS: PriceVersion[] = [ // SMS: Turkey gets cheaper above 10k/month (B1) { id: "sms-tr-list", meter: "otp/sms", scope: "list", dims: { country: "TR" }, effectiveFrom: 0, rate: { model: "graduated", tiers: [ { from: 0, unitPriceMicroUsd: 20_000 }, { from: 10_000, unitPriceMicroUsd: 15_000 }, ], policy: { tierPeriod: "month" }, }, }, { id: "sms-pro", meter: "otp/sms", scope: "plan:pro", dims: { country: "TR" }, effectiveFrom: 0, rate: flat(18_000) }, { id: "wa-list", meter: "otp/whatsapp", scope: "list", effectiveFrom: 0, rate: flat(10_000) }, { id: "verify-list", meter: "otp/verify", scope: "list", effectiveFrom: 0, rate: flat(5_000) }, // platform fee per message after 1,000 free each month, across channels (B4) { id: "pool-list", meter: "msg/free_pool", scope: "list", effectiveFrom: 0, rate: { model: "graduated", tiers: [ { from: 0, unitPriceMicroUsd: 0 }, { from: 1000, unitPriceMicroUsd: 2_000 }, ], policy: { tierPeriod: "month" }, }, }, // WhatsApp conversation windows, per country, as Meta publishes them { id: "wa-window-tr", meter: "wa/window", scope: "list", dims: { country: "TR" }, effectiveFrom: 0, rate: flat(9_000) }, // Dedicated IP: $50 once per month, whatever else happens (B11) { id: "ip-list", meter: "ip/dedicated", scope: "list", effectiveFrom: 0, rate: { model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: 0, flatMicroUsd: 50_000_000 }], policy: { tierPeriod: "month" } }, },];Türkiye SMS’i aylık 10.000’den sonra ucuzlar (B1), pro planın kendi satırı vardır, özel IP ayda bir kez $50’dır (B11). Tabloda satırı olmayan SMS ülkeleri için fiyat, at anındaki operatör maliyeti + %25 marjdır (B23):
export function routeRate(routes: readonly RouteCost[], countryCode: string, at: number): Rate | null { const current = routes.filter((r) => r.country === countryCode && r.from <= at).sort((a, b) => b.from - a.from)[0]; if (!current) return null; return { id: `route-${countryCode}-${current.from}`, model: "graduated", tiers: [{ from: 0, unitPriceMicroUsd: Math.ceil(current.costMicroUsd * ROUTE_MARGIN) }] };}
/** Table first, then route cost for SMS. */export function findRate(i: { rows: readonly PriceVersion[]; routes: readonly RouteCost[]; meter: string; dims: Record<string, unknown>; ctx: Context; at: number }): Rate | null { const fromTable = resolveLayered({ rows: i.rows, scopes: scopesFor(i.ctx), dims: i.dims, at: i.at, meter: i.meter }); if (fromTable) return fromTable; if (i.meter === "otp/sms") return routeRate(i.routes, String(i.dims.country), i.at); return null;}Worker: bind ve OTP akışı
Bölüm başlığı “Worker: bind ve OTP akışı”const metering = catalog.bind({ getRate: async (meter, dims, ctx, at) => { const rate: Rate | null = findRate({ rows: await deps.priceRows(), routes: await deps.routes(), meter, dims, ctx, at }); if (!rate) return null; const multiTier = rate.tiers.length > 1 || rate.tiers.some((t) => t.flatMicroUsd); return multiTier ? { rate, usedSoFar: await deps.account(ctx.accountId).used(meter, monthKey({ at })) } : { rate }; }, commit: doCommit((id) => deps.account(id)),});usedSoFar yalnızca çok kademeli ya da sabit ücretli tarifede istenir ve hesabın DO’sundan, monthKey({ at }) dönemi için okunur.
/** Send: reserve the message segments and one verification in a single two-line hold. */async sendOtp(i: { id: string; ctx: Context; channel: "sms" | "whatsapp"; country: string; text: string; at: number }) { if (sandboxed(i.ctx)) return { ok: true as const, sandbox: true }; const meter = i.channel === "sms" ? "otp/sms" : "otp/whatsapp"; const h = await metering.hold( [ { meter, dims: { country: i.country }, quantity: segments(i.text) }, { meter: "otp/verify", quantity: 1 }, ], { type: "otp_send", id: i.id }, i.ctx, { at: i.at }, ); if (!h.ok) return h; await deps.otps.put({ id: i.id, ctx: i.ctx, channel: i.channel, country: i.country, hold: h.hold, sends: 1 }); return h;},/** Verified: capture what was actually sent plus the verification, release the rest. */async verifyOtp(i: { id: string; delivered: number; at: number }) { const otp = await deps.otps.get(i.id); if (!otp) return { ok: false as const, reason: "unknown_otp" }; const meter = otp.channel === "sms" ? "otp/sms" : "otp/whatsapp"; const c = await metering.capture( otp.hold, [ { meter, quantity: i.delivered }, { meter: "otp/verify", quantity: 1 }, ], otp.ctx, { at: i.at }, ); if (!c.ok) return c; const r = await metering.release(c.hold, otp.ctx, { at: i.at }); if (r.ok) await deps.otps.delete(i.id); return { ...c, released: r.ok };},Gönderimde iki satırlı bir hold (segmentler + bir doğrulama) açılır ve OTP kaydının üstünde saklanır; tekrar gönderim aynı hold’u extend eder; doğrulamada gönderilen segmentler ve doğrulama yakalanır, kalan serbest bırakılır. Süresi dolan OTP yalnızca teslim edilen segmentleri öder.
Gömülü fiyatlama ve geç fiyatlar
Bölüm başlığı “Gömülü fiyatlama ve geç fiyatlar”/** * A WhatsApp conversation window closed. Embedded mode: the Worker plans with its view, the account DO * re-prices with its own counters and applies. If the country's price isn't published yet, queue it: * the retry uses the original `at`. */async closeWindow(m: WindowMessage) { if (sandboxed(m.ctx)) return { ok: true as const, sandbox: true }; const planned = await metering.plan.observe("wa/window", { country: m.country }, 1, { type: "wa_window", id: m.windowId }, m.ctx, { at: m.at }); if (!planned.result.ok) { if (planned.result.reason === "no_price") await deps.windowQueue.send(m); return planned.result; } return deps.account(m.ctx.accountId).observeEmbedded(planned.lines, { type: "wa_window", id: m.windowId }, m.ctx);},Worker plan.observe ile satırları fiyatlar ama yazmaz; hesabın DO’su onları kendi sayaçlarıyla yeniden fiyatlar ve aynı adımda uygular (gömülü mod). Ülkenin pencere fiyatı henüz yayımlanmadıysa sonuç no_price’tır: mesaj kuyruğa girer, bir saat sonra aynı at ile yeniden denenir.
export class AccountCore implements AccountApi { constructor( private storage: DurableStorageLike, private sink: (rows: readonly UsageRow[]) => Promise<void>, private opts: { prepaid: boolean } = { prepaid: true }, ) { migrateAccount(storage.sql); }
async commit(plan: Plan): Promise<CommitReply> { return commitReply(this.storage, plan, { prepaid: this.opts.prepaid, periodOf }); }
async used(meter: string, period: string): Promise<number> { return accountUsed(this.storage.sql, meter, period); }
async observeEmbedded(lines: CatalogLine[], ref: CatalogRef, ctx: Context): Promise<Result> { // Nothing awaits between reading counters and writing: the DO's input gate makes this atomic. const fresh = lines.map((l) => ({ ...l, usedSoFar: accountUsed(this.storage.sql, l.meter, periodOf({ at: l.at ?? Date.now() })) })); const { result, plan } = catalog.price(fresh, ref, ctx); if (!result.ok) return result; const reply = commitReply(this.storage, plan, { prepaid: this.opts.prepaid, periodOf }); return reply.ok ? result : { ok: false, reason: "insufficient_credit" }; }
async credit(amountMicroUsd: number): Promise<void> { creditAccount(this.storage.sql, amountMicroUsd); }
async state(): Promise<AccountState> { return accountState(this.storage.sql); }
async flush(): Promise<number> { let total = 0; for (let n = -1; n !== 0; total += n) n = await flushOutbox(this.storage.sql, this.sink); return total; }}HTTP eşlemesi
Bölüm başlığı “HTTP eşlemesi”/** HTTP mapping of results: the status code is the app's decision, not the library's. */function reply(r: { ok: boolean; reason?: string }) { if (r.ok) return json(r); const status = r.reason === "insufficient_credit" ? 402 : r.reason === "no_price" ? 422 : r.reason === "unknown_otp" ? 404 : 400; return json(r, status);}Durum kodu kütüphanenin değil uygulamanın kararıdır: insufficient_credit → 402, no_price → 422.