İçeriğe geç

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

Terminal window
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
examples/messaging/src/catalog.ts
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.

examples/messaging/src/prices.ts
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):

examples/messaging/src/prices.ts
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;
}
examples/messaging/src/app.ts
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.

examples/messaging/src/app.ts
/** 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;
},
examples/messaging/src/app.ts
/** 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.

examples/messaging/src/app.ts
/**
* 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.

examples/messaging/src/account.ts
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;
}
}
examples/messaging/src/worker.ts
/** 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.