Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/alchemy/src/Cloudflare/Providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -73,6 +74,7 @@ export const providers = () =>
Queue.QueueEventSourcePolicy,
R2.R2Bucket,
R2.R2BucketBindingPolicy,
RateLimit.RateLimitBindingPolicy,
SecretsStore.SecretBindingPolicy,
SecretsStore.SecretsStore,
SecretsStore.Secret,
Expand Down Expand Up @@ -112,6 +114,7 @@ export const providers = () =>
Queue.QueueConsumerProvider(),
R2.R2BucketBindingPolicyLive,
R2.R2BucketProvider(),
RateLimit.RateLimitBindingPolicyLive,
SecretsStore.SecretBindingPolicyLive,
SecretsStore.SecretsStoreProvider(),
SecretsStore.StoreSecretProvider(),
Expand Down
116 changes: 116 additions & 0 deletions packages/alchemy/src/Cloudflare/RateLimit/RateLimit.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Worker>;
* // { 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<RateLimit>;
/**
* 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<typeof RateLimitBinding.bind>) =>
RateLimitBinding.bind(...args),
},
);
90 changes: 90 additions & 0 deletions packages/alchemy/src/Cloudflare/RateLimit/RateLimitBinding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/// <reference types="@cloudflare/workers-types" />

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<cf.RateLimit, never, WorkerEnvironment>;
limit(
options: Parameters<cf.RateLimit["limit"]>[0],
): Effect.Effect<
Awaited<ReturnType<cf.RateLimit["limit"]>>,
RateLimitError,
WorkerEnvironment
>;
}

export class RateLimitBinding extends Binding.Service<
RateLimitBinding,
(rateLimit: RateLimitLike) => Effect.Effect<RateLimitClient>
>()("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<string, cf.RateLimit>)[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<void>
>()("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}'`),
);
}
}),
);
2 changes: 2 additions & 0 deletions packages/alchemy/src/Cloudflare/RateLimit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./RateLimit.ts";
export * from "./RateLimitBinding.ts";
20 changes: 11 additions & 9 deletions packages/alchemy/src/Cloudflare/Workers/InferEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,17 @@ export type GetBindingType<T> =
? AnalyticsEngineDataset
: T extends Cloudflare.Artifacts
? Artifacts
: T extends Cloudflare.Images
? ImagesBinding
: T extends Cloudflare.Hyperdrive
? Hyperdrive
: T extends Cloudflare.DurableObjectNamespaceLike
? DurableObjectNamespace<
Exclude<T["Shape"], undefined>
>
: T;
: T extends Cloudflare.RateLimit
? RateLimit
: T extends Cloudflare.Images
? ImagesBinding
: T extends Cloudflare.Hyperdrive
? Hyperdrive
: T extends Cloudflare.DurableObjectNamespaceLike
? DurableObjectNamespace<
Exclude<T["Shape"], undefined>
>
: T;

/**
* Cloudflare service-binding wire shape for an Effect-native Worker.
Expand Down
Loading
Loading