diff --git a/examples/cloudflare-rate-limit/index.ts b/examples/cloudflare-rate-limit/index.ts new file mode 100644 index 0000000000..f3a815d211 --- /dev/null +++ b/examples/cloudflare-rate-limit/index.ts @@ -0,0 +1,14 @@ +import { Resource } from "sst/resource"; + +export default { + async fetch(req: Request) { + const url = new URL(req.url); + + const outcome = await Resource.MyRateLimit.limit({ key: url.pathname }); + if (!outcome.success) { + return new Response(`Rate limit exceeded for ${url.pathname}`, { status: 429 }); + } + + return new Response("OK", { status: 200 }); + }, +}; diff --git a/examples/cloudflare-rate-limit/package.json b/examples/cloudflare-rate-limit/package.json new file mode 100644 index 0000000000..9c3fc082c8 --- /dev/null +++ b/examples/cloudflare-rate-limit/package.json @@ -0,0 +1,9 @@ +{ + "name": "cloudflare-rate-limit", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@cloudflare/workers-types": "^4.20260426.1", + "sst": "file:../../sdk/js" + } +} diff --git a/examples/cloudflare-rate-limit/sst.config.ts b/examples/cloudflare-rate-limit/sst.config.ts new file mode 100644 index 0000000000..1dfa0a61c2 --- /dev/null +++ b/examples/cloudflare-rate-limit/sst.config.ts @@ -0,0 +1,33 @@ +/// + +/** + * ## Cloudflare Rate Limit + * + * This example creates a Cloudflare Rate Limit and a Worker that applies it. + */ +export default $config({ + app(input) { + return { + name: "cloudflare-rate-limit", + removal: input?.stage === "production" ? "retain" : "remove", + home: "cloudflare", + }; + }, + async run() { + const rateLimit = new sst.cloudflare.RateLimit("MyRateLimit", { + namespaceId: 1001, + limit: 100, + period: "1 minute", + }); + + const worker = new sst.cloudflare.Worker("MyWorker", { + handler: "./index.ts", + url: true, + link: [rateLimit], + }); + + return { + api: worker.url, + }; + }, +}); diff --git a/examples/cloudflare-rate-limit/tsconfig.json b/examples/cloudflare-rate-limit/tsconfig.json new file mode 100644 index 0000000000..4d3c77fe88 --- /dev/null +++ b/examples/cloudflare-rate-limit/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "lib": ["esnext"], + "types": ["@cloudflare/workers-types"] + } +} diff --git a/pkg/types/typescript/typescript.go b/pkg/types/typescript/typescript.go index 345493ba30..d49c70b5b0 100644 --- a/pkg/types/typescript/typescript.go +++ b/pkg/types/typescript/typescript.go @@ -27,6 +27,7 @@ var mapping = map[string]string{ "hyperdriveBindings": "Hyperdrive", "versionMetadataBindings": "WorkerVersionMetadata", "workflowBindings": "Workflow", + "rateLimitBindings": "RateLimit", } var header = strings.Join([]string{ diff --git a/platform/src/components/cloudflare/binding.ts b/platform/src/components/cloudflare/binding.ts index cc901601f4..e5004fe3a8 100644 --- a/platform/src/components/cloudflare/binding.ts +++ b/platform/src/components/cloudflare/binding.ts @@ -88,6 +88,17 @@ export interface WorkflowBinding { }; } +export interface RateLimitBinding { + type: "rateLimitBindings"; + properties: { + namespaceId: Input; + simple: Input<{ + limit: Input; + period: Input; + }>; + }; +} + export type Binding = | AiBinding | KvBinding @@ -99,7 +110,8 @@ export type Binding = | D1DatabaseBinding | HyperdriveBinding | VersionMetadataBinding - | WorkflowBinding; + | WorkflowBinding + | RateLimitBinding; export function binding(input: Binding & {}) { return { diff --git a/platform/src/components/cloudflare/helpers/wrangler.ts b/platform/src/components/cloudflare/helpers/wrangler.ts index cee8d207a6..adcb6b0191 100644 --- a/platform/src/components/cloudflare/helpers/wrangler.ts +++ b/platform/src/components/cloudflare/helpers/wrangler.ts @@ -53,6 +53,7 @@ export function createWranglerConfig(input: { const services: Record[] = []; const queueProducers: Record[] = []; const workflows: Record[] = []; + const rateLimits: Record[] = []; let ai: Record | undefined; let versionMetadata: Record | undefined; @@ -135,6 +136,13 @@ export function createWranglerConfig(input: { remote: true, }); break; + case "rateLimitBindings": + rateLimits.push({ + name: link.name, + namespace_id: stringValue(properties.namespaceId), + simple: properties.simple, + }); + break; } } @@ -170,6 +178,9 @@ export function createWranglerConfig(input: { if (workflows.length > 0) { config.workflows = workflows; } + if (rateLimits.length > 0) { + config.rate_limits = rateLimits; + } return config; } diff --git a/platform/src/components/cloudflare/index.ts b/platform/src/components/cloudflare/index.ts index 0d571c5893..0938d23c84 100644 --- a/platform/src/components/cloudflare/index.ts +++ b/platform/src/components/cloudflare/index.ts @@ -15,6 +15,7 @@ export * from "./astro"; export * from "./react-router"; export * from "./tan-stack-start"; export * from "./workflow"; +export * from "./rate-limit"; export { binding } from "./binding.js"; /** diff --git a/platform/src/components/cloudflare/rate-limit.ts b/platform/src/components/cloudflare/rate-limit.ts new file mode 100644 index 0000000000..2af65e2aac --- /dev/null +++ b/platform/src/components/cloudflare/rate-limit.ts @@ -0,0 +1,178 @@ +import { + ComponentResourceOptions, + output, + type Input, + type Output, +} from "@pulumi/pulumi"; +import { Component } from "../component"; +import { Link } from "../link"; +import { binding } from "./binding"; +import { toSeconds } from "../duration"; +import { VisibleError } from "../error"; + +export interface RateLimitArgs { + /** + * A positive integer that uniquely defines this rate limiting namespace within your Cloudflare account. + */ + namespaceId: Input; + /** + * The number of allowed requests within the specified period of time. + */ + limit: Input; + /** + * The duration of the rate limit window. Must be either 10 seconds or 1 minute. + */ + period: Input<"10 seconds" | "1 minute">; +} + +/** + * The `RateLimit` component lets you add a [Cloudflare Rate Limit](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) binding to + * your app. + * + * @example + * + * #### Minimal example + * + * ```ts title="sst.config.ts" + * const rateLimit = new sst.cloudflare.RateLimit("MyRateLimit", { + * namespaceId: 1001, + * limit: 100, + * period: "1 minute", + * }); + * ``` + * + * #### Link to a worker + * + * You can link RateLimit to a worker. + * + * ```ts {4} title="sst.config.ts" + * new sst.cloudflare.Worker("MyWorker", { + * handler: "./index.ts", + * url: true + * link: [rateLimit], + * }); + * ``` + * + * Once linked, you can use the SDK to interact with the RateLimit binding. + * + * ```ts title="index.ts" {7} + * import { Resource } from "sst/resource"; + * + * export default { + * async fetch(req, env): Promise { + * const url = new URL(req.url); + * + * const outcome = await Resource.MyRateLimit.limit({ key: url.pathname }); + * if (!outcome.success) { + * return new Response(`Rate limit exceeded for ${url.pathname}`, { status: 429 }); + * } + * + * return new Response("OK", { status: 200 }); + * } + * } + * ``` + */ +export class RateLimit extends Component implements Link.Linkable { + private _namespaceId: Output; + private _limit: Output; + private _period: Output; + + constructor( + name: string, + args: RateLimitArgs, + opts?: ComponentResourceOptions, + ) { + super(__pulumiType, name, args, opts); + + const namespaceId = normalizeNamespaceId(); + const limit = output(args.limit); + const period = normalizePeriod(); + + this._namespaceId = namespaceId; + this._limit = limit; + this._period = period; + + function normalizeNamespaceId() { + return output(args.namespaceId).apply((namespaceId) => { + if (!Number.isInteger(namespaceId) || namespaceId <= 0) { + throw new VisibleError( + "The `namespaceId` property must be a positive integer.", + ); + } + + return namespaceId.toString(); + }); + } + + function normalizePeriod() { + return output(args.period).apply(toSeconds); + } + } + + /** + * A unique identifier for the rate limit namespace. + */ + public get namespaceId() { + return this._namespaceId; + } + + /** + * The number of allowed requests within the specified period of time. + */ + public get limit() { + return this._limit; + } + + /** + * The duration of the rate limit window, in seconds. + */ + public get period() { + return this._period; + } + + /** + * When you link a RateLimit binding, it will be available to the worker and you can + * interact with it using its [API methods](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/). + * + * @example + * ```ts title="index.ts" + * import { Resource } from "sst/resource"; + * + * export default { + * async fetch(req, env): Promise { + * const url = new URL(req.url); + * + * const outcome = await Resource.MyRateLimit.limit({ key: url.pathname }); + * if (!outcome.success) { + * return new Response(`Rate limit exceeded for ${url.pathname}`, { status: 429 }); + * } + * + * return new Response("OK", { status: 200 }); + * } + * } + * ``` + * + * @internal + */ + getSSTLink() { + return { + properties: {}, + include: [ + binding({ + type: "rateLimitBindings", + properties: { + namespaceId: this._namespaceId, + simple: { + limit: this._limit, + period: this._period, + }, + }, + }), + ], + }; + } +} + +const __pulumiType = "sst:cloudflare:RateLimit"; +// @ts-expect-error +RateLimit.__pulumiType = __pulumiType; diff --git a/platform/src/components/cloudflare/worker.ts b/platform/src/components/cloudflare/worker.ts index 595b23642e..1ab8028696 100644 --- a/platform/src/components/cloudflare/worker.ts +++ b/platform/src/components/cloudflare/worker.ts @@ -528,6 +528,7 @@ export class Worker extends Component implements Link.Linkable { hyperdriveBindings: "hyperdrive", versionMetadataBindings: "version_metadata", workflowBindings: "workflow", + rateLimitBindings: "ratelimit" }[b.binding], name, ...b.properties, diff --git a/www/astro.config.mjs b/www/astro.config.mjs index f8835346da..e1d2012058 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -232,6 +232,7 @@ const sidebar = [ "docs/component/cloudflare/worker", "docs/component/cloudflare/bucket", "docs/component/cloudflare/workflow", + "docs/component/cloudflare/rate-limit", { label: "StaticSite", slug: "docs/component/cloudflare/static-site-v2" }, "docs/component/cloudflare/hyperdrive", "docs/component/cloudflare/react-router", diff --git a/www/generate.ts b/www/generate.ts index ba9048865e..6d500cddc7 100644 --- a/www/generate.ts +++ b/www/generate.ts @@ -2537,6 +2537,7 @@ async function buildComponents() { "../platform/src/components/cloudflare/react-router.ts", "../platform/src/components/cloudflare/worker.ts", "../platform/src/components/cloudflare/workflow.ts", + "../platform/src/components/cloudflare/rate-limit.ts", "../platform/src/components/cloudflare/static-site.ts", "../platform/src/components/cloudflare/static-site-v2.ts", "../platform/src/components/cloudflare/tan-stack-start.ts", diff --git a/www/src/content/docs/docs/cloudflare.mdx b/www/src/content/docs/docs/cloudflare.mdx index 80aa41fa23..c2580b21c3 100644 --- a/www/src/content/docs/docs/cloudflare.mdx +++ b/www/src/content/docs/docs/cloudflare.mdx @@ -130,7 +130,7 @@ For scheduled work, use [`Cron`](/docs/component/cloudflare/cron/) to run a work ### More components -Browse the component docs for [`Worker`](/docs/component/cloudflare/worker/), [`Astro`](/docs/component/cloudflare/astro/), [`Bucket`](/docs/component/cloudflare/bucket/), [`D1`](/docs/component/cloudflare/d1/), [`Kv`](/docs/component/cloudflare/kv/), [`Queue`](/docs/component/cloudflare/queue/), [`Cron`](/docs/component/cloudflare/cron/), [`Ai`](/docs/component/cloudflare/ai/), and [`Workflow`](/docs/component/cloudflare/workflow/). +Browse the component docs for [`Worker`](/docs/component/cloudflare/worker/), [`Astro`](/docs/component/cloudflare/astro/), [`Bucket`](/docs/component/cloudflare/bucket/), [`D1`](/docs/component/cloudflare/d1/), [`Kv`](/docs/component/cloudflare/kv/), [`Queue`](/docs/component/cloudflare/queue/), [`Cron`](/docs/component/cloudflare/cron/), [`Ai`](/docs/component/cloudflare/ai/), [`Workflow`](/docs/component/cloudflare/workflow/), and [`RateLimit`](/docs/component/cloudflare/rate-limit/). If you are using Cloudflare DNS with SST, use [`sst.cloudflare.dns`](/docs/component/cloudflare/dns/) with [custom domains](/docs/custom-domains/).