From 28519c4481dc79973c9c8d5dd056da0a6958c407 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 5 May 2026 12:47:02 +0200 Subject: [PATCH] feat(cloudflare/ratelimit): add RateLimit binding --- packages/alchemy/src/Cloudflare/Providers.ts | 3 + .../src/Cloudflare/RateLimit/RateLimit.ts | 116 ++++++++++++++++++ .../Cloudflare/RateLimit/RateLimitBinding.ts | 90 ++++++++++++++ .../alchemy/src/Cloudflare/RateLimit/index.ts | 2 + .../src/Cloudflare/Workers/InferEnv.ts | 20 +-- .../Cloudflare/Workers/WorkerAsyncBindings.ts | 100 ++++++++------- .../src/Cloudflare/Workers/WorkerBinding.ts | 2 + packages/alchemy/src/Cloudflare/index.ts | 1 + .../Cloudflare/RateLimit/RateLimit.test.ts | 116 ++++++++++++++++++ .../Cloudflare/RateLimit/fixtures/worker.ts | 3 + 10 files changed, 398 insertions(+), 55 deletions(-) create mode 100644 packages/alchemy/src/Cloudflare/RateLimit/RateLimit.ts create mode 100644 packages/alchemy/src/Cloudflare/RateLimit/RateLimitBinding.ts create mode 100644 packages/alchemy/src/Cloudflare/RateLimit/index.ts create mode 100644 packages/alchemy/test/Cloudflare/RateLimit/RateLimit.test.ts create mode 100644 packages/alchemy/test/Cloudflare/RateLimit/fixtures/worker.ts diff --git a/packages/alchemy/src/Cloudflare/Providers.ts b/packages/alchemy/src/Cloudflare/Providers.ts index 4a5b9298a..500162bc1 100644 --- a/packages/alchemy/src/Cloudflare/Providers.ts +++ b/packages/alchemy/src/Cloudflare/Providers.ts @@ -27,6 +27,7 @@ import * as Images from "./Images/index.ts"; import * as KV from "./KV/index.ts"; import * as Queue from "./Queue/index.ts"; import * as R2 from "./R2/index.ts"; +import * as RateLimit from "./RateLimit/index.ts"; import * as SecretsStore from "./SecretsStore/index.ts"; import * as Tunnel from "./Tunnel/index.ts"; import * as VpcService from "./VpcService/index.ts"; @@ -73,6 +74,7 @@ export const providers = () => Queue.QueueEventSourcePolicy, R2.R2Bucket, R2.R2BucketBindingPolicy, + RateLimit.RateLimitBindingPolicy, SecretsStore.SecretBindingPolicy, SecretsStore.SecretsStore, SecretsStore.Secret, @@ -112,6 +114,7 @@ export const providers = () => Queue.QueueConsumerProvider(), R2.R2BucketBindingPolicyLive, R2.R2BucketProvider(), + RateLimit.RateLimitBindingPolicyLive, SecretsStore.SecretBindingPolicyLive, SecretsStore.SecretsStoreProvider(), SecretsStore.StoreSecretProvider(), diff --git a/packages/alchemy/src/Cloudflare/RateLimit/RateLimit.ts b/packages/alchemy/src/Cloudflare/RateLimit/RateLimit.ts new file mode 100644 index 000000000..656a5ec72 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/RateLimit/RateLimit.ts @@ -0,0 +1,116 @@ +import * as Effect from "effect/Effect"; +import { RateLimitBinding } from "./RateLimitBinding.ts"; + +type RateLimitTypeId = typeof RateLimitTypeId; +const RateLimitTypeId = "Cloudflare.RateLimit" as const; + +export type RateLimitPeriod = 10 | 60; + +export type RateLimitProps = { + /** + * Binding name used when `Cloudflare.RateLimit.bind(rateLimit)` attaches the + * binding from inside a Worker init phase. When RateLimit is passed through + * `Worker({ bindings: { ... } })`, the object key remains the binding name. + * + * @default "RATE_LIMIT" + */ + name?: string; + /** + * Positive integer or string that uniquely identifies this rate limit + * configuration. + */ + namespaceId: number | string; + /** + * Simple rate limiting configuration. + */ + simple: { + /** + * The number of requests allowed within the period. + */ + limit: number; + /** + * The period, in seconds, over which requests are counted. + */ + period: RateLimitPeriod; + }; +}; + +export type RateLimit = { + kind: RateLimitTypeId; + name: string; + namespaceId: string; + simple: { + limit: number; + period: RateLimitPeriod; + }; +}; + +export const isRateLimit = (value: unknown): value is RateLimit => + typeof value === "object" && + value !== null && + "kind" in value && + (value as RateLimit).kind === RateLimitTypeId; + +/** + * A Cloudflare Rate Limit binding for counting arbitrary keys inside Workers. + * + * Rate Limit bindings are configured directly on Workers and do not have a + * standalone provisioning API. The Worker provider sees this object in + * `bindings: { ... }` and emits the corresponding `{ type: "ratelimit" }` + * metadata binding to the script. + * + * @section Declaring a Rate Limit + * @example + * ```typescript + * const signupThrottle = yield* Cloudflare.RateLimit({ + * namespaceId: 1001, + * simple: { limit: 10, period: 60 }, + * }); + * ``` + * + * @section Binding to a Worker + * @example + * ```typescript + * export const Worker = Cloudflare.Worker("Worker", { + * main: "./src/worker.ts", + * bindings: { SIGNUP_THROTTLE: signupThrottle }, + * }); + * + * export type WorkerEnv = Cloudflare.InferEnv; + * // { SIGNUP_THROTTLE: RateLimit } + * ``` + * + * @section Effect-style Worker + * @example + * ```typescript + * Cloudflare.Worker("Worker", props, Effect.gen(function* () { + * const signupThrottle = yield* Cloudflare.RateLimit.bind(SignupThrottle); + * }).pipe(Effect.provide(Cloudflare.RateLimitBindingLive))); + * ``` + * + * @see https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/ + */ +export const RateLimit: { + (props: RateLimitProps): Effect.Effect; + /** + * Bind Cloudflare Rate Limit to the surrounding Worker, returning an + * Effect-native client with access to the native Workers runtime binding. + */ + bind: typeof RateLimitBinding.bind; +} = Object.assign( + Effect.fn(function* (props: RateLimitProps) { + return { + kind: RateLimitTypeId, + name: props.name ?? "RATE_LIMIT", + namespaceId: String(props.namespaceId), + simple: { + limit: props.simple.limit, + period: props.simple.period, + }, + } satisfies RateLimit; + }), + { + bind: (...args: Parameters) => + RateLimitBinding.bind(...args), + }, +); diff --git a/packages/alchemy/src/Cloudflare/RateLimit/RateLimitBinding.ts b/packages/alchemy/src/Cloudflare/RateLimit/RateLimitBinding.ts new file mode 100644 index 000000000..1fc7b9b90 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/RateLimit/RateLimitBinding.ts @@ -0,0 +1,90 @@ +/// + +import type * as cf from "@cloudflare/workers-types"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Binding from "../../Binding.ts"; +import type { ResourceLike } from "../../Resource.ts"; +import { isWorker, WorkerEnvironment } from "../Workers/Worker.ts"; +import { type RateLimit as RateLimitLike } from "./RateLimit.ts"; + +export class RateLimitError extends Data.TaggedError("RateLimitError")<{ + message: string; + cause: unknown; +}> {} + +export interface RateLimitClient { + raw: Effect.Effect; + limit( + options: Parameters[0], + ): Effect.Effect< + Awaited>, + RateLimitError, + WorkerEnvironment + >; +} + +export class RateLimitBinding extends Binding.Service< + RateLimitBinding, + (rateLimit: RateLimitLike) => Effect.Effect +>()("Cloudflare.RateLimit.Binding") {} + +export const RateLimitBindingLive = Layer.effect( + RateLimitBinding, + Effect.gen(function* () { + const Policy = yield* RateLimitBindingPolicy; + + return Effect.fn(function* (rateLimit: RateLimitLike) { + yield* Policy(rateLimit); + const raw = WorkerEnvironment.useSync( + (env) => (env as Record)[rateLimit.name]!, + ); + return { + raw, + limit: (options) => + raw.pipe( + Effect.flatMap((binding) => + Effect.tryPromise({ + try: () => binding.limit(options), + catch: (error) => + new RateLimitError({ + message: + error instanceof Error + ? error.message + : "Unknown RateLimit error", + cause: error, + }), + }), + ), + ), + } satisfies RateLimitClient; + }); + }), +); + +export class RateLimitBindingPolicy extends Binding.Policy< + RateLimitBindingPolicy, + (rateLimit: RateLimitLike) => Effect.Effect +>()("Cloudflare.RateLimit.Binding") {} + +export const RateLimitBindingPolicyLive = RateLimitBindingPolicy.layer.succeed( + Effect.fn(function* (host: ResourceLike, rateLimit: RateLimitLike) { + if (isWorker(host)) { + yield* host.bind(rateLimit.name, { + bindings: [ + { + type: "ratelimit", + name: rateLimit.name, + namespaceId: rateLimit.namespaceId, + simple: rateLimit.simple, + } as any, + ], + }); + } else { + return yield* Effect.die( + new Error(`RateLimitBinding does not support runtime '${host.Type}'`), + ); + } + }), +); diff --git a/packages/alchemy/src/Cloudflare/RateLimit/index.ts b/packages/alchemy/src/Cloudflare/RateLimit/index.ts new file mode 100644 index 000000000..dda854f33 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/RateLimit/index.ts @@ -0,0 +1,2 @@ +export * from "./RateLimit.ts"; +export * from "./RateLimitBinding.ts"; diff --git a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts index 5ddba6566..0d683a789 100644 --- a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts +++ b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts @@ -39,15 +39,17 @@ export type GetBindingType = ? AnalyticsEngineDataset : T extends Cloudflare.Artifacts ? Artifacts - : T extends Cloudflare.Images - ? ImagesBinding - : T extends Cloudflare.Hyperdrive - ? Hyperdrive - : T extends Cloudflare.DurableObjectNamespaceLike - ? DurableObjectNamespace< - Exclude - > - : T; + : T extends Cloudflare.RateLimit + ? RateLimit + : T extends Cloudflare.Images + ? ImagesBinding + : T extends Cloudflare.Hyperdrive + ? Hyperdrive + : T extends Cloudflare.DurableObjectNamespaceLike + ? DurableObjectNamespace< + Exclude + > + : T; /** * Cloudflare service-binding wire shape for an Effect-native Worker. diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index eae68ad9d..10bc6e843 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -8,6 +8,7 @@ import { isAnalyticsEngineDataset } from "../AnalyticsEngine/AnalyticsEngineData import { isArtifacts } from "../Artifacts/Artifacts.ts"; import { isSendEmail } from "../Email/SendEmail.ts"; import { isImages } from "../Images/Images.ts"; +import { isRateLimit } from "../RateLimit/RateLimit.ts"; import { isAssets } from "./Assets.ts"; import { isDurableObjectNamespaceLike } from "./DurableObjectNamespace.ts"; import type { WorkerBindingProps } from "./Worker.ts"; @@ -55,83 +56,90 @@ export const bindWorkerAsyncBindings = Effect.fnUntraced(function* ( name: bindingName, namespace: binding.namespace, } as any) - : isImages(binding) - ? { - type: "images", + : isRateLimit(binding) + ? ({ + type: "ratelimit", name: bindingName, - } - : isAnalyticsEngineDataset(binding) + namespaceId: binding.namespaceId, + simple: binding.simple, + } as any) + : isImages(binding) ? { - type: "analytics_engine", + type: "images", name: bindingName, - dataset: binding.dataset, } - : isSendEmail(binding) + : isAnalyticsEngineDataset(binding) ? { - type: "send_email", + type: "analytics_engine", name: bindingName, - destinationAddress: binding.destinationAddress, - allowedDestinationAddresses: - binding.allowedDestinationAddresses, - allowedSenderAddresses: - binding.allowedSenderAddresses, + dataset: binding.dataset, } - : isDurableObjectNamespaceLike(binding) + : isSendEmail(binding) ? { - type: "durable_object_namespace", + type: "send_email", name: bindingName, - className: binding.className ?? binding.name, + destinationAddress: binding.destinationAddress, + allowedDestinationAddresses: + binding.allowedDestinationAddresses, + allowedSenderAddresses: + binding.allowedSenderAddresses, } - : binding.Type === "Cloudflare.D1Database" + : isDurableObjectNamespaceLike(binding) ? { - type: "d1", - id: binding.databaseId, + type: "durable_object_namespace", name: bindingName, + className: binding.className ?? binding.name, } - : binding.Type === "Cloudflare.R2Bucket" + : binding.Type === "Cloudflare.D1Database" ? { - type: "r2_bucket", + type: "d1", + id: binding.databaseId, name: bindingName, - bucketName: binding.bucketName, - jurisdiction: binding.jurisdiction.pipe( - Output.map((jurisdiction) => - jurisdiction === "default" - ? undefined - : jurisdiction, - ), - ), } - : binding.Type === "Cloudflare.KVNamespace" + : binding.Type === "Cloudflare.R2Bucket" ? { - type: "kv_namespace", + type: "r2_bucket", name: bindingName, - namespaceId: binding.namespaceId, + bucketName: binding.bucketName, + jurisdiction: binding.jurisdiction.pipe( + Output.map((jurisdiction) => + jurisdiction === "default" + ? undefined + : jurisdiction, + ), + ), } - : binding.Type === "Cloudflare.Queue" + : binding.Type === "Cloudflare.KVNamespace" ? { - type: "queue", + type: "kv_namespace", name: bindingName, - queueName: binding.queueName, + namespaceId: binding.namespaceId, } - : binding.Type === "Cloudflare.AiGateway" + : binding.Type === "Cloudflare.Queue" ? { - type: "ai", + type: "queue", name: bindingName, + queueName: binding.queueName, } - : binding.Type === "Cloudflare.Hyperdrive" + : binding.Type === "Cloudflare.AiGateway" ? { - type: "hyperdrive", + type: "ai", name: bindingName, - id: binding.hyperdriveId, } - : isWorker(binding) + : binding.Type === "Cloudflare.Hyperdrive" ? { - type: "service", + type: "hyperdrive", name: bindingName, - service: binding.workerName, + id: binding.hyperdriveId, } - : // TODO(sam): handle others - undefined; + : isWorker(binding) + ? { + type: "service", + name: bindingName, + service: binding.workerName, + } + : // TODO(sam): handle others + undefined; if (bindingMeta) { yield* resource.bind`${bindingName}`({ diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts index 82923d3a2..34feb902d 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts @@ -13,6 +13,7 @@ import { Images } from "../Images/Images.ts"; import type { KVNamespace } from "../KV/KVNamespace.ts"; import type { Queue } from "../Queue/Queue.ts"; import type { R2Bucket } from "../R2/R2Bucket.ts"; +import type { RateLimit } from "../RateLimit/RateLimit.ts"; import type { Assets } from "./Assets.ts"; import type { DurableObjectNamespaceLike } from "./DurableObjectNamespace.ts"; import { makeRpcStub } from "./Rpc.ts"; @@ -38,6 +39,7 @@ export type WorkerBindingResource = | AnalyticsEngineDataset | SendEmail | Artifacts + | RateLimit | Images | Hyperdrive | Worker diff --git a/packages/alchemy/src/Cloudflare/index.ts b/packages/alchemy/src/Cloudflare/index.ts index daf0aa21a..dd79e538d 100644 --- a/packages/alchemy/src/Cloudflare/index.ts +++ b/packages/alchemy/src/Cloudflare/index.ts @@ -14,6 +14,7 @@ export * from "./KV/index.ts"; export * from "./Providers.ts"; export * from "./Queue/index.ts"; export * from "./R2/index.ts"; +export * from "./RateLimit/index.ts"; export * from "./SecretsStore/index.ts"; export * from "./StateStore/index.ts"; export * from "./Tunnel/index.ts"; diff --git a/packages/alchemy/test/Cloudflare/RateLimit/RateLimit.test.ts b/packages/alchemy/test/Cloudflare/RateLimit/RateLimit.test.ts new file mode 100644 index 000000000..72b2e5d5a --- /dev/null +++ b/packages/alchemy/test/Cloudflare/RateLimit/RateLimit.test.ts @@ -0,0 +1,116 @@ +import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as Test from "@/Test/Vitest"; +import * as workers from "@distilled.cloud/cloudflare/workers"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as pathe from "pathe"; + +const { test } = Test.make({ providers: Cloudflare.providers() }); +const main = pathe.resolve(import.meta.dirname, "fixtures/worker.ts"); + +type Expect = T; +type Extends = T extends U ? true : false; +type RateLimitEnv = Cloudflare.InferEnv<{ + RATE_LIMIT: ReturnType; +}>; +type RateLimitLimit = RateLimitEnv["RATE_LIMIT"]["limit"]; +type _RateLimitBindingAcceptsKey = Expect< + Extends<{ key: string }, Parameters[0]> +>; +type _RateLimitBindingReturnsPromise = Expect< + Extends, Promise> +>; + +test.provider("worker bindings emit Cloudflare RateLimit metadata", (stack) => + Effect.gen(function* () { + const { accountId } = yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const worker = yield* stack.deploy( + Effect.gen(function* () { + return yield* Cloudflare.Worker("RateLimitedWorker", { + main, + bindings: { + SIGNUP_THROTTLE: Cloudflare.RateLimit({ + name: "IGNORED_BY_DIRECT_BINDING", + namespaceId: 10_003, + simple: { limit: 60, period: 60 }, + }), + }, + }); + }), + ); + + const settings = yield* workers.getScriptScriptAndVersionSetting({ + accountId, + scriptName: worker.workerName, + }); + expect(settings.bindings).toEqual( + expect.arrayContaining([ + { + type: "ratelimit", + name: "SIGNUP_THROTTLE", + namespaceId: "10003", + simple: { + limit: 60, + period: 60, + }, + }, + ]), + ); + + yield* stack.destroy(); + }), +); + +test.provider( + "init-phase binding emits Cloudflare RateLimit metadata", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const worker = yield* stack.deploy( + Effect.gen(function* () { + const rateLimit = yield* Cloudflare.RateLimit({ + name: "PUBLIC_SIGNUP_THROTTLE", + namespaceId: "10004", + simple: { limit: 1, period: 60 }, + }); + + return yield* Cloudflare.Worker( + "RateLimitedWorker", + { + main, + }, + Effect.gen(function* () { + yield* Cloudflare.RateLimit.bind(rateLimit); + }).pipe(Effect.provide(Cloudflare.RateLimitBindingLive)), + ); + }), + ); + + const settings = yield* workers.getScriptScriptAndVersionSetting({ + accountId, + scriptName: worker.workerName, + }); + expect(settings.bindings).toEqual( + expect.arrayContaining([ + { + type: "ratelimit", + name: "PUBLIC_SIGNUP_THROTTLE", + namespaceId: "10004", + simple: { + limit: 1, + period: 60, + }, + }, + ]), + ); + + yield* stack.destroy(); + }), +); diff --git a/packages/alchemy/test/Cloudflare/RateLimit/fixtures/worker.ts b/packages/alchemy/test/Cloudflare/RateLimit/fixtures/worker.ts new file mode 100644 index 000000000..d4875496d --- /dev/null +++ b/packages/alchemy/test/Cloudflare/RateLimit/fixtures/worker.ts @@ -0,0 +1,3 @@ +export default { + fetch: async () => new Response("ok"), +};