Rate limits, analytics, browsers, images, flags and more
Once you’ve met one resource, you’ve met them all: a class that declares
it and is its client, a .bind(...) the compiler checks, and a stand-in
for tests. This page is a tour of the rest, built around two example
apps:
example/links, a link shortener: a rate limit on making links, and Analytics Engine counting the clicks.example/snap, page snapshots: Browser Rendering takes a picture, Images shrinks it, and an R2 bucket’s notifications tell a queue.
Both have run live. At the end, a few more that are built, and waiting for an account that can test them.
Rate limits
Section titled “Rate limits”A rate limit allows limit calls a period for each key, such as an
address or a user. The link shortener allows five new links a minute
from one address:
/** Five new links a minute from one address. */export class Creates extends RateLimit.Make<Creates>()('creates', { limit: 5, period: 60 }) {}limit(key) says whether the key has a call left, and counts one if so:
const request = yield* Worker.Request;if (!(yield* Effect.orDie((yield* Creates).limit(request.headers.get('cf-connecting-ip') ?? 'unknown')))) { return yield* new TooManyLinks();}The period is 10 or 60 seconds, as Cloudflare counts; anything else is
refused where you declare it. Each environment counts apart from the
others (Cloudflare tells limits apart by a number, which forge-effect
derives from the app, environment and name; namespace pins one).
In tests, RateLimit.memory(Creates) counts exactly, in memory, and
infra dev wires a local one:
const runtime = ManagedRuntime.make(Layer.mergeAll(Kv.memory(Links, []), RateLimit.memory(Creates), clicks));Analytics Engine
Section titled “Analytics Engine”An Analytics Engine dataset takes data points: a few strings and numbers, written fast and never waited on, that you query later with SQL. You name the fields once:
/** A click: which link, from where, and from which page. */export class Clicks extends Analytics.Dataset<Clicks>()('clicks', { index: 'slug', blobs: ['country', 'referrer'] }) {}Each field is a blob (a string), a double (a number, under
doubles), or the index (the string Cloudflare samples by). write
takes them by name, typed, and puts each where Cloudflare numbers it:
const { country = 'unknown' } = yield* Worker.cf;const referrer = (yield* Worker.Request).headers.get('referer') ?? '';yield* (yield* Clicks).write({ slug, country, referrer });To read them back, use Analytics Engine’s SQL API. Its columns are
numbered, not named, so Clicks.columns tells you which is which:
assert.deepEqual(Clicks.columns, { slug: 'index1', country: 'blob1', referrer: 'blob2' });The dataset is named <app>_<name>, so here links_clicks:
SELECT index1 AS slug, blob1 AS country, count() AS clicksFROM links_clicks GROUP BY slug, country ORDER BY clicks DESCCloudflare makes the dataset when the first point is written, and keeps
points for three months. There’s no deleting one, so there’s nothing for
destroy to do. In tests, Analytics.memory(Clicks) keeps the points,
as Cloudflare would store them:
const clicks = Analytics.memory(Clicks);// … follow a link …assert.deepEqual(clicks.points(), [{ blobs: ['unknown', 'https://news.example/'], indexes: ['docs'] }]);Checked live: links made and followed, and each click a point the SQL API returned, with its country.
What Cloudflare knows: Worker.cf
Section titled “What Cloudflare knows: Worker.cf”That country came from Worker.cf: what Cloudflare knows about the
request being handled. Its country, city, region and timezone, the
datacenter that answered (colo, as an airport code), the network
(asn, asOrganization), and the protocol. Each is unset when
Cloudflare doesn’t know, as under infra dev and in tests. Hence the
country = 'unknown' default above.
Browser Rendering
Section titled “Browser Rendering”Browser Rendering gives your Worker a real headless Chrome. Its binding
is what a browser library launches with, so declare it, and hand it to
@cloudflare/puppeteer:
export class Chrome extends Browser.Make<Chrome>()('browser') {}const snap = (url: string) => Effect.scoped( Effect.gen(function* () { // A browser for this scope, closed when it ends. const browser = yield* Browser.session(Chrome, (binding) => puppeteer.launch(binding)); const png = yield* Effect.promise(async () => { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.goto(url, { waitUntil: 'load' }); return (await page.screenshot({ type: 'png' })) as Uint8Array; }); // … shrink it and keep it, below }), );Browser.session is the nice part. Cloudflare bills a browser while it’s
open, so a forgotten close() costs money. session ties the browser to
the Effect’s scope: when Effect.scoped ends, by success, failure or
interruption, the browser closes.
@cloudflare/puppeteer needs Node’s APIs, so the Worker turns on the
nodejs_compat flag:
export default Worker.make('snap', { fetch, queues: [listed], compatibilityFlags: ['nodejs_compat'] }).bind(Chrome, Pictures, Shots, Listing);Browser.memory(Chrome) stands in for tests that don’t launch one: a
launch through it fails, saying there’s no browser in this test.
Dynamic Workers
Section titled “Dynamic Workers”A Dynamic Worker is a Worker you make while yours runs, from code you hand it, sandboxed. It has its own page: Dynamic Workers. That page covers typing a guest against an interface, checking a model’s code before it runs, and a guest’s own workflows, objects and static files.
Images
Section titled “Images”The Images binding reads and transforms images inside your Worker:
export class Pictures extends Images.Make<Pictures>()('images') {}transform takes the bytes, a list of transformations done in order
(width, height, fit, rotate, blur), and the format to encode
as. The snapshot app shrinks its 1280-pixel screenshot to a 480-pixel
WebP:
const thumb = yield* (yield* Pictures).transform(png, [{ width: 480 }], { format: 'image/webp', quality: 80 });const key = `${new URL(url).hostname}-${yield* Clock.currentTimeMillis}.webp`;yield* (yield* Shots).put(key, thumb.bytes, { contentType: thumb.contentType });info reads an image’s format and, for a raster image, its size:
// An upload's size, before deciding what to do with it.export const sizeOf = (bytes: Uint8Array) => Effect.gen(function* () { const { format, width, height } = yield* (yield* Pictures).info(bytes); return `${format}, ${width ?? '?'}×${height ?? '?'}`; });Images.memory(Pictures) stands in for tests: transform hands the
image back as it was, typed as you asked, and records each list of
transformations in done().
Bucket notifications
Section titled “Bucket notifications”An R2 bucket can tell a queue whenever an object changes. The snapshot app uses that to list each new snapshot in D1, without the upload code knowing about the listing:
/** Each new snapshot, as the bucket says it. */export class Made extends Queue.Make<Made>()('made', { schema: R2.Notification }) {}
export class Shots extends R2.Bucket<Shots>()('shots', { notify: [{ queue: Made, actions: ['PutObject'], suffix: '.webp' }],}) {}notify names the queue, which actions to tell it of (PutObject,
CopyObject, DeleteObject, CompleteMultipartUpload,
LifecycleDeletion), and optionally a key prefix or suffix. The
queue’s schema must be R2.Notification, the message Cloudflare sends;
a queue of anything else doesn’t compile. Then consume it like any queue
(see Queues), the message typed:
// Each new snapshot, as the bucket tells of it, listed.const listed = Made.consume((made) => Effect.orDie( Effect.flatMap(Listing, (db) => db.run('INSERT OR REPLACE INTO shots (key, size, at) VALUES (?, ?, ?)', made.object.key, made.object.size ?? 0, made.eventTime)), ),);A queue is made only if a Worker of the app binds or consumes it, so a
bucket that notifies one nobody does is refused when the app loads
(notification-queue-unbound): “the bucket loud notifies the queue
lost, which no Worker of the app binds or consumes, so it isn’t made”.
On a ship, each notification is set once its queue exists, and recorded; one you stop declaring is removed by the next ship. A teardown clears them all before deleting the queues, as Cloudflare won’t delete a queue a bucket still notifies. In tests, run the consumer with a notification you write yourself:
const runtime = ManagedRuntime.make(Layer.mergeAll(R2.memory(Shots), Testing.sqlite(Listing), Browser.memory(Chrome), Images.memory(Pictures)));const notification: R2.Notification = { account: 'acc', action: 'PutObject', bucket: 'snap-shots', object: { key: 'example.com-1.webp', size: 3, eTag: 'e' }, eventTime: '2026-10-17T01:00:00.000Z',};const message = { id: 'm', timestamp: new Date(), body: notification, attempts: 1, ack: () => {}, retry: () => {} };await runtime.runPromise( Effect.gen(function* () { yield* (yield* Shots).put('example.com-1.webp', new Uint8Array([1, 2, 3]), { contentType: 'image/webp' }); yield* Worker.runQueue(snap, 'made', [message]); }),);Checked live: a snapshot of example.com taken, shrunk to a 7 KB WebP, kept, and listed through the bucket’s notification. The first teardowns found two Cloudflare quirks, now handled: it refuses the empty body the SDK sends to delete a notification (so it’s deleted by its rules’ ids), and its Queues API gives a queue’s id without hyphens where the notifications API has them.
Email: sending and receiving
Section titled “Email: sending and receiving”A Worker receives email with an email handler, at the addresses it
declares, and sends it with an Email.Sender.
example/inbox
is a support inbox, on both:
export class Pager extends Email.Sender<Pager>()('pager', { destination: 'oncall@example.com', allowedSenders: ['inbox@mail.example.com'] }) {}
const email = Effect.gen(function* () { const message = yield* Worker.IncomingEmail; if (blocked.includes(message.from)) { message.setReject('this address is blocked'); return; } const subject = message.headers.get('subject') ?? '(no subject)'; yield* Effect.orDie((yield* Mail).put(message.headers.get('message-id') ?? `${yield* Clock.currentTimeMillis}`, JSON.stringify({ from: message.from, subject }))); yield* Effect.promise(() => message.forward('team@example.com')); if (/urgent/i.test(subject)) { yield* Effect.orDie((yield* Pager).send({ from: { email: 'inbox@mail.example.com', name: 'Support inbox' }, subject: `urgent mail from ${message.from}`, text: subject })); }});
export default Worker.make('inbox', { email, addresses: ['support@example.com'] }).bind(Mail, Pager);Receiving. addresses are the Email Routing addresses the handler
gets: literal ones, or a zone’s catch-all, *@example.com. With
subaddressing on, support@ gets support+anything@ too, and
message.to keeps the tag. Ship gives the Worker their rules as Wrangler
does (rules the Worker owns, so wrangler deploy and cf deploy agree
with them), takes away those no longer declared, and removes them all on
teardown. It stops before anything is made if an address is in no zone of
the account, in one without Email Routing on, or already routed elsewhere
(--take-routes takes it). A list is production’s alone; a function of
the environment’s name gives others theirs. A rule just made takes about
a minute to route mail.
Sending. From an address of a domain onboarded to Email Sending (a
subdomain, such as mail.example.com, keeps its reputation apart).
send gives the message id; a refusal is an EmailError with
Cloudflare’s code (E_RECIPIENT_NOT_ALLOWED, E_DAILY_LIMIT_EXCEEDED, …)
and whether a retry may work. destination, allowedDestinations and
allowedSenders restrict it, and Cloudflare enforces them.
The token needs, for the addresses, Email Routing Rules: Edit on the zone and Email Routing Account Rules: Read on the account (the plan of what changes is the account’s).
In tests, Worker.runEmail runs the handler on a message you make, and
Email.memory(Pager) keeps what was sent, refusing what Cloudflare would
for the sender’s restrictions. infra dev leaves a sender unset, as it
runs only on Cloudflare.
Checked live (October 18), on a real zone: a Worker’s email went out
through Email Sending and back in through Email Routing to its handler in
seconds, at its address and at a +tag one, with SPF, DKIM and DMARC
passing; an address the binding doesn’t allow was refused; and a message
to an outside inbox landed in the inbox, not spam.
mTLS certificates and Hyperdrive
Section titled “mTLS certificates and Hyperdrive”Both are made in your account apart from any deploy, and bound by their id.
class Bank extends Mtls.Certificate<Bank>()('bank-cert', { id: 'cert-1' }) {}class Orders extends Hyperdrive.Config<Orders>()('orders', { id: 'hd-1', local: 'postgres://localhost:5432/orders' }) {}const fetch = Effect.gen(function* () { const response = yield* (yield* Bank).fetch('https://api.bank.example/balance'); const orders = yield* Orders; return new Response(`${yield* Effect.promise(() => response.text())} ${orders.host}:${orders.port}/${orders.database}`);}).pipe(Effect.orDie);const worker = Worker.make('payments', { fetch }).bind(Bank, Orders);Mtls.Certificate’s client is afetchthat presents the certificate to servers that ask for one.Mtls.memory(Bank, handler)plays the server in tests. Upload the certificate first (the dashboard’s mTLS certificates, or the API, which needs SSL and Certificates on the account); Cloudflare takes a leaf certificate with its key, not a CA’s. Checked live (October 18): through the binding, a server that asks for a client certificate was shown the uploaded one, and named it; a plainfetchto it showed none, and was refused.Hyperdrive.Config’s client is the connection:connectionStringandpasswordareRedacted, so they never land in a log by accident;host,port,useranddatabaseare plain. HandRedacted.value(connectionString)to a driver such aspostgres.localis the databaseinfra devconnects to instead (without one, dev says connecting will fail).Hyperdrive.memory(Orders, url)stands in for tests.
Feature flags
Section titled “Feature flags”Flagship is Cloudflare’s feature flags: turn a feature on for some users, roll it out to a percentage, or switch it off, from the dashboard, without a deploy. You declare the flags your code reads, each with its type and a fallback:
import { Flagship, Worker } from '@forge-effect/infra';import { Schema } from 'effect';
const Limits = Schema.Struct({ perMinute: Schema.Finite, burst: Schema.Finite });
export class Flags extends Flagship.App<Flags>()('flags', { flags: { 'new-home': Flagship.boolean(false), checkout: Flagship.string('classic', { choices: ['classic', 'one-page'] }), 'max-items': Flagship.number(20), limits: Flagship.json(Limits, { perMinute: 60, burst: 10 }), },}) {}Reading one gives its type, and never fails:
const flags = yield* Flags;const home = yield* flags.get('new-home', { targetingKey: user.id, plan: user.plan }); // booleanconst checkout = yield* flags.get('checkout'); // 'classic' | 'one-page'The second argument is what the flag’s rules match on: plan here, or
any attribute you pass. targetingKey decides which side of a rollout a
user lands on, and keeps them there. details gives the variation and
the reason too (TARGETING_MATCH, SPLIT, DEFAULT…).
A deploy makes the app, one per environment, and each flag it hasn’t yet, serving its fallback. After that, the flags are yours to change in the dashboard: no deploy changes a flag that exists, so the rules and rollouts you set there stay. A flag that can’t be read (deleted, changed to another type, a string outside its choices, JSON your schema doesn’t decode) reads as its fallback, and logs a warning.
In tests, Flagship.memory serves the values you give, or a function of
the context; the rest serve their fallbacks:
Flagship.memory(Flags, { 'new-home': (context) => context.plan === 'pro' });infra dev runs the app locally, its flags made as a deploy makes them;
change one in Miniflare’s local explorer, and it stays so.
Bindings without a client yet
Section titled “Bindings without a client yet”Cloudflare ships bindings faster than anyone can wrap them. For one that
Cloudflare’s config library, @cloudflare/config, takes but forge-effect
has no Effect client for yet (dispatch namespaces, Pipelines, VPC
services and networks, AI Search namespaces, Media), Cloudflare.Binding
declares it as the config does, and hands you the runtime’s object as it
is:
import { Cloudflare, Worker } from '@forge-effect/infra';
/** The part of a pipeline's binding the Worker uses. */interface Pipeline { send(records: ReadonlyArray<object>): Promise<void>;}
class Clicks extends Cloudflare.Binding<Clicks, Pipeline>()('clicks', (env) => ({ type: 'pipeline', name: env === 'production' ? 'clicks' : `clicks-${env}`,})) {}
const fetch = Effect.gen(function* () { const clicks = yield* Clicks; yield* Effect.promise(() => clicks.send([{ path: '/' }])); return new Response('counted');});
export const site = Worker.make('site', { fetch }).bind(Clicks);The settings are checked by the config’s types; a function of the environment’s name gives each environment its own. The object’s type is the second type parameter, and the compiler takes your word for it.
For what even the config doesn’t model, type: 'unsafe:<type>' takes the
upload’s binding as it is, in its snake_case form:
class Beta extends Cloudflare.Binding<Beta>()('beta', { type: 'unsafe:beta_thing', some_id: 'b-1' }) {}infra dev runs what Miniflare can and leaves an unsafe: binding
unset; Cloudflare.memory(Clicks, { send: async () => {} }) stands in
for tests.
- Secrets: API keys and passwords, never in a file.
- Settings and observability: placement, limits, logs and Tail Workers.