Skip to content

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.

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:

example/links/resources.ts
/** 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:

example/links/links.ts
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:

test/links.test.ts
const runtime = ManagedRuntime.make(Layer.mergeAll(Kv.memory(Links, []), RateLimit.memory(Creates), clicks));

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:

example/links/resources.ts
/** 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:

example/links/links.ts
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:

test/links.test.ts
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 clicks
FROM links_clicks GROUP BY slug, country ORDER BY clicks DESC

Cloudflare 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:

test/links.test.ts
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.

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 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:

example/snap/resources.ts
export class Chrome extends Browser.Make<Chrome>()('browser') {}
example/snap/snap.ts
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:

example/snap/snap.ts
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.

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.

The Images binding reads and transforms images inside your Worker:

example/snap/resources.ts
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:

example/snap/snap.ts
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:

size.ts
// 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().

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:

example/snap/resources.ts
/** 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:

example/snap/snap.ts
// 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:

test/snap.test.ts
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.

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:

example/inbox/inbox.ts
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.

Both are made in your account apart from any deploy, and bound by their id.

test/untested.test.ts
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 a fetch that 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 plain fetch to it showed none, and was refused.
  • Hyperdrive.Config’s client is the connection: connectionString and password are Redacted, so they never land in a log by accident; host, port, user and database are plain. Hand Redacted.value(connectionString) to a driver such as postgres. local is the database infra dev connects to instead (without one, dev says connecting will fail). Hyperdrive.memory(Orders, url) stands in for tests.

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:

flags.ts
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 }); // boolean
const 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.

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:

clicks.ts
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.