From 9fca352a7d5b8c7aec6cd3db6b13edd383bdfd57 Mon Sep 17 00:00:00 2001 From: Matthew Rodgers Date: Fri, 23 May 2025 14:51:07 -0700 Subject: [PATCH 1/5] WC-3584 Refactor routing to asset or user worker Centralizing some of these pieces will make it easier to add free tier limiting --- .../router-worker/src/worker.ts | 96 +++++++------------ 1 file changed, 36 insertions(+), 60 deletions(-) diff --git a/packages/workers-shared/router-worker/src/worker.ts b/packages/workers-shared/router-worker/src/worker.ts index be02db0515fe..4b9f9746aaf4 100644 --- a/packages/workers-shared/router-worker/src/worker.ts +++ b/packages/workers-shared/router-worker/src/worker.ts @@ -77,6 +77,37 @@ export default { const maybeSecondRequest = request.clone(); + const routeToUserWorker = async ({ asset }: { asset: "static_routing" | "none" }) => { + if (!config.has_user_worker) { + throw new Error( + "Fetch for user worker without having a user worker binding" + ); + } + analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER }); + userWorkerInvocation = true; + return env.JAEGER.enterSpan("dispatch_worker", async (span) => { + span.setTags({ + hasUserWorker: true, + asset: asset, + dispatchType: DISPATCH_TYPE.WORKER, + }); + return env.USER_WORKER.fetch(maybeSecondRequest); + }); + }; + + const routeToAssets = async ({ asset }: { asset: "static_routing" | "found" | "none" }) => { + analytics.setData({ dispatchtype: DISPATCH_TYPE.ASSETS }); + return await env.JAEGER.enterSpan("dispatch_assets", async (span) => { + span.setTags({ + hasUserWorker: config.has_user_worker, + asset: asset, + dispatchType: DISPATCH_TYPE.ASSETS, + }); + + return env.ASSET_WORKER.fetch(maybeSecondRequest); + }); + }; + if (config.static_routing) { // evaluate "exclude" rules const excludeRulesMatcher = generateStaticRoutingRuleMatcher( @@ -89,18 +120,9 @@ export default { ) { // direct to asset worker analytics.setData({ - dispatchtype: DISPATCH_TYPE.ASSETS, staticRoutingDecision: STATIC_ROUTING_DECISION.ROUTED, }); - return await env.JAEGER.enterSpan("dispatch_assets", async (span) => { - span.setTags({ - hasUserWorker: config.has_user_worker, - asset: "static_routing", - dispatchType: DISPATCH_TYPE.ASSETS, - }); - - return env.ASSET_WORKER.fetch(maybeSecondRequest); - }); + return await routeToAssets({ asset: "static_routing" }); } // evaluate "include" rules const includeRulesMatcher = generateStaticRoutingRuleMatcher( @@ -118,19 +140,9 @@ export default { } // direct to user worker analytics.setData({ - dispatchtype: DISPATCH_TYPE.WORKER, staticRoutingDecision: STATIC_ROUTING_DECISION.ROUTED, }); - return await env.JAEGER.enterSpan("dispatch_worker", async (span) => { - span.setTags({ - hasUserWorker: true, - asset: "static_routing", - dispatchType: DISPATCH_TYPE.WORKER, - }); - - userWorkerInvocation = true; - return env.USER_WORKER.fetch(maybeSecondRequest); - }); + return await routeToUserWorker({ asset: "static_routing" }); } analytics.setData({ @@ -143,53 +155,17 @@ export default { // User's configuration indicates they want user-Worker to run ahead of any // assets. Do not provide any fallback logic. if (config.invoke_user_worker_ahead_of_assets) { - if (!config.has_user_worker) { - throw new Error( - "Fetch for user worker without having a user worker binding" - ); - } - - analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER }); - return await env.JAEGER.enterSpan("dispatch_worker", async (span) => { - span.setTags({ - hasUserWorker: true, - asset: "ignored", - dispatchType: DISPATCH_TYPE.WORKER, - }); - - userWorkerInvocation = true; - return env.USER_WORKER.fetch(maybeSecondRequest); - }); + return await routeToUserWorker({ asset: "static_routing" }); } // If we have a user-Worker, but no assets, dispatch to Worker script const assetsExist = await env.ASSET_WORKER.unstable_canFetch(request); if (config.has_user_worker && !assetsExist) { - analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER }); - - return await env.JAEGER.enterSpan("dispatch_worker", async (span) => { - span.setTags({ - hasUserWorker: config.has_user_worker, - asset: assetsExist, - dispatchType: DISPATCH_TYPE.WORKER, - }); - - userWorkerInvocation = true; - return env.USER_WORKER.fetch(maybeSecondRequest); - }); + return await routeToUserWorker({ asset: "none" }); } // Otherwise, we either don't have a user worker, OR we have matching assets and should fetch from the assets binding - analytics.setData({ dispatchtype: DISPATCH_TYPE.ASSETS }); - return await env.JAEGER.enterSpan("dispatch_assets", async (span) => { - span.setTags({ - hasUserWorker: config.has_user_worker, - asset: assetsExist, - dispatchType: DISPATCH_TYPE.ASSETS, - }); - - return env.ASSET_WORKER.fetch(maybeSecondRequest); - }); + return await routeToAssets({ asset: assetsExist ? "found" : "none" }); } catch (err) { if (userWorkerInvocation) { // Don't send user Worker errors to sentry; we have no way to distinguish between From 430864ddfd16462286d8c6045ec6c952bc1d1ed9 Mon Sep 17 00:00:00 2001 From: Matthew Rodgers Date: Fri, 23 May 2025 15:22:02 -0700 Subject: [PATCH 2/5] WC-3584 Add eyeball routing config Provided via a new optional param binding. When provided, it contains info about free tier limiting for this account. --- .../router-worker/src/configuration.ts | 14 ++++++++++-- .../router-worker/src/worker.ts | 22 +++++++++++++++---- .../router-worker/wrangler.jsonc | 6 +++++ packages/workers-shared/utils/types.ts | 8 +++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/workers-shared/router-worker/src/configuration.ts b/packages/workers-shared/router-worker/src/configuration.ts index 3dde3943505d..2c44712dac58 100644 --- a/packages/workers-shared/router-worker/src/configuration.ts +++ b/packages/workers-shared/router-worker/src/configuration.ts @@ -1,6 +1,8 @@ -import type { RouterConfig } from "../../utils/types"; +import type { EyeballRouterConfig, RouterConfig } from "../../utils/types"; -export const applyConfigurationDefaults = ( +type RequiredEyeballRouterConfig = Required>; + +export const applyRouterConfigDefaults = ( configuration?: RouterConfig ): Required => { return { @@ -15,3 +17,11 @@ export const applyConfigurationDefaults = ( }, }; }; + +export const applyEyeballConfigDefaults = ( + eyeballConfiguration?: EyeballRouterConfig +): RequiredEyeballRouterConfig => { + return { + limitedAssetsOnly: eyeballConfiguration?.limitedAssetsOnly ?? false, + }; +}; diff --git a/packages/workers-shared/router-worker/src/worker.ts b/packages/workers-shared/router-worker/src/worker.ts index 4b9f9746aaf4..092016013c09 100644 --- a/packages/workers-shared/router-worker/src/worker.ts +++ b/packages/workers-shared/router-worker/src/worker.ts @@ -3,9 +3,13 @@ import { PerformanceTimer } from "../../utils/performance"; import { setupSentry } from "../../utils/sentry"; import { mockJaegerBinding } from "../../utils/tracing"; import { Analytics, DISPATCH_TYPE, STATIC_ROUTING_DECISION } from "./analytics"; -import { applyConfigurationDefaults } from "./configuration"; +import { + applyEyeballConfigDefaults, + applyRouterConfigDefaults, +} from "./configuration"; import type AssetWorker from "../../asset-worker"; import type { + EyeballRouterConfig, JaegerTracing, RouterConfig, UnsafePerformanceTimer, @@ -16,6 +20,7 @@ export interface Env { ASSET_WORKER: Service; USER_WORKER: Fetcher; CONFIG: RouterConfig; + EYEBALL_CONFIG: EyeballRouterConfig; SENTRY_DSN: string; ENVIRONMENT: Environment; @@ -55,7 +60,8 @@ export default { ); const hasStaticRouting = env.CONFIG.static_routing !== undefined; - const config = applyConfigurationDefaults(env.CONFIG); + const config = applyRouterConfigDefaults(env.CONFIG); + const eyeballConfig = applyEyeballConfigDefaults(env.EYEBALL_CONFIG); const url = new URL(request.url); @@ -77,7 +83,11 @@ export default { const maybeSecondRequest = request.clone(); - const routeToUserWorker = async ({ asset }: { asset: "static_routing" | "none" }) => { + const routeToUserWorker = async ({ + asset, + }: { + asset: "static_routing" | "none"; + }) => { if (!config.has_user_worker) { throw new Error( "Fetch for user worker without having a user worker binding" @@ -95,7 +105,11 @@ export default { }); }; - const routeToAssets = async ({ asset }: { asset: "static_routing" | "found" | "none" }) => { + const routeToAssets = async ({ + asset, + }: { + asset: "static_routing" | "found" | "none"; + }) => { analytics.setData({ dispatchtype: DISPATCH_TYPE.ASSETS }); return await env.JAEGER.enterSpan("dispatch_assets", async (span) => { span.setTags({ diff --git a/packages/workers-shared/router-worker/wrangler.jsonc b/packages/workers-shared/router-worker/wrangler.jsonc index 33d62792427e..e89f322ed55d 100644 --- a/packages/workers-shared/router-worker/wrangler.jsonc +++ b/packages/workers-shared/router-worker/wrangler.jsonc @@ -25,6 +25,12 @@ "type": "param", "param": "routerConfig", }, + { + "name": "EYEBALL_CONFIG", + "type": "param", + "param": "eyeballRouterConfig", + "optional": true, + }, { "name": "ASSET_WORKER", "type": "internal_assets", diff --git a/packages/workers-shared/utils/types.ts b/packages/workers-shared/utils/types.ts index 9bcfefdb9c30..1cd4374b3c11 100644 --- a/packages/workers-shared/utils/types.ts +++ b/packages/workers-shared/utils/types.ts @@ -20,6 +20,13 @@ export const RouterConfigSchema = z.object({ ...InternalConfigSchema.shape, }); +export const EyeballRouterConfigSchema = z.union([ + z.object({ + limitedAssetsOnly: z.boolean().optional(), + }), + z.null(), +]); + const MetadataStaticRedirectEntry = z.object({ status: z.number(), to: z.string(), @@ -79,6 +86,7 @@ export const AssetConfigSchema = z.object({ ...InternalConfigSchema.shape, }); +export type EyeballRouterConfig = z.infer; export type RouterConfig = z.infer; export type AssetConfig = z.infer; From db8b771e2bf27ab6b1c54a6311fe9e9550b4f964 Mon Sep 17 00:00:00 2001 From: Matthew Rodgers Date: Fri, 23 May 2025 15:23:47 -0700 Subject: [PATCH 3/5] WC-3584 Deny requests to the user worker when over free tier limits Returns an html page (included as direct module, via esbuild) with a 429 status code, mirroring the current free tier limiting behavior. It also sets a new field in our analytics so we can observe free tier invocation denials. This page can evolve as we need it to, this is just my first pass at the response. --- .../router-worker/src/analytics.ts | 3 + .../router-worker/src/limited-response.html | 21 +++ .../router-worker/src/worker.ts | 10 ++ .../router-worker/tests/index.test.ts | 159 ++++++++++++++++++ .../workers-shared/router-worker/types.d.ts | 4 + 5 files changed, 197 insertions(+) create mode 100644 packages/workers-shared/router-worker/src/limited-response.html create mode 100644 packages/workers-shared/router-worker/types.d.ts diff --git a/packages/workers-shared/router-worker/src/analytics.ts b/packages/workers-shared/router-worker/src/analytics.ts index b86e325471e8..a4ebf8b6d0fa 100644 --- a/packages/workers-shared/router-worker/src/analytics.ts +++ b/packages/workers-shared/router-worker/src/analytics.ts @@ -33,6 +33,8 @@ type Data = { userWorkerAhead?: boolean; // double6 - Routing performed based on the _routes.json (if provided) staticRoutingDecision?: STATIC_ROUTING_DECISION; + // double7 - User worker invocation denied due to free tier limiting + userWorkerFreeTierLimiting?: boolean; // -- Blobs -- // blob1 - Hostname of the request @@ -88,6 +90,7 @@ export class Analytics { ? -1 : Number(this.data.userWorkerAhead), this.data.staticRoutingDecision ?? STATIC_ROUTING_DECISION.NOT_PROVIDED, // double6 + this.data.userWorkerFreeTierLimiting ? 1 : 0, // double7 ], blobs: [ this.data.hostname?.substring(0, 256), // blob1 - trim to 256 bytes diff --git a/packages/workers-shared/router-worker/src/limited-response.html b/packages/workers-shared/router-worker/src/limited-response.html new file mode 100644 index 000000000000..0e851e10c98b --- /dev/null +++ b/packages/workers-shared/router-worker/src/limited-response.html @@ -0,0 +1,21 @@ + + + This website is temporarily limited + + +

This website has been temporarily rate limited

+

+ You cannot access this site because the owner has reached their plan + limits. Check back later once traffic has gone down. +

+

+ If you are owner of this website, prevent this from happening again by + upgrading your plan on the + Cloudflare Workers dashboard. +

+ + diff --git a/packages/workers-shared/router-worker/src/worker.ts b/packages/workers-shared/router-worker/src/worker.ts index 092016013c09..882ae10c5821 100644 --- a/packages/workers-shared/router-worker/src/worker.ts +++ b/packages/workers-shared/router-worker/src/worker.ts @@ -7,6 +7,7 @@ import { applyEyeballConfigDefaults, applyRouterConfigDefaults, } from "./configuration"; +import limitedResponse from "./limited-response.html"; import type AssetWorker from "../../asset-worker"; import type { EyeballRouterConfig, @@ -93,6 +94,15 @@ export default { "Fetch for user worker without having a user worker binding" ); } + if (eyeballConfig.limitedAssetsOnly) { + analytics.setData({ userWorkerFreeTierLimiting: true }); + return new Response(limitedResponse, { + status: 429, + headers: { + "Content-Type": "text/html", + }, + }); + } analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER }); userWorkerInvocation = true; return env.JAEGER.enterSpan("dispatch_worker", async (span) => { diff --git a/packages/workers-shared/router-worker/tests/index.test.ts b/packages/workers-shared/router-worker/tests/index.test.ts index 9916c9e6126d..35b842fbe04b 100644 --- a/packages/workers-shared/router-worker/tests/index.test.ts +++ b/packages/workers-shared/router-worker/tests/index.test.ts @@ -246,4 +246,163 @@ describe("unit tests", async () => { const response = await worker.fetch(request, env, ctx); expect(await response.text()).toEqual("hello from user worker"); }); + + describe("free tier limiting", () => { + it("returns fetch from asset worker for assets", async () => { + const request = new Request("https://example.com/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return true; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(await response.text()).toEqual("hello from asset worker"); + }); + + it("returns error page instead of user worker when no asset found", async () => { + const request = new Request("https://example.com/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return false; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(response.status).toEqual(429); + const text = await response.text(); + expect(text).not.toEqual("hello from user worker"); + expect(text).toContain("This website has been temporarily rate limited"); + }); + + it("returns error page instead of user worker for invoke_user_worker_ahead_of_assets", async () => { + const request = new Request("https://example.com/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + invoke_user_worker_ahead_of_assets: true, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return true; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(response.status).toEqual(429); + const text = await response.text(); + expect(text).not.toEqual("hello from user worker"); + expect(text).toContain("This website has been temporarily rate limited"); + }); + + it("returns error page instead of user worker for user_worker rules", async () => { + const request = new Request("https://example.com/api/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + static_routing: { + user_worker: ["/api/*"], + }, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return true; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(response.status).toEqual(429); + const text = await response.text(); + expect(text).not.toEqual("hello from user worker"); + expect(text).toContain("This website has been temporarily rate limited"); + }); + + it("returns fetch from asset worker for asset_worker rules", async () => { + const request = new Request("https://example.com/api/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + static_routing: { + user_worker: ["/api/*"], + asset_worker: ["/api/asset"], + }, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return true; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(await response.text()).toEqual("hello from asset worker"); + }); + }); }); diff --git a/packages/workers-shared/router-worker/types.d.ts b/packages/workers-shared/router-worker/types.d.ts new file mode 100644 index 000000000000..6d78d737f920 --- /dev/null +++ b/packages/workers-shared/router-worker/types.d.ts @@ -0,0 +1,4 @@ +declare module "*.html" { + const content: string; + export default content; +} From 16c5d561c2e0166d9b0b0c3dd039c11715c0122d Mon Sep 17 00:00:00 2001 From: Matthew Rodgers Date: Fri, 23 May 2025 15:47:47 -0700 Subject: [PATCH 4/5] WC-3584 Add changeset --- .changeset/tidy-bobcats-lose.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-bobcats-lose.md diff --git a/.changeset/tidy-bobcats-lose.md b/.changeset/tidy-bobcats-lose.md new file mode 100644 index 000000000000..06ba5c4e919e --- /dev/null +++ b/.changeset/tidy-bobcats-lose.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/workers-shared": minor +--- + +Limit free tier requests in the Router worker From 4c257b02643d9cf9b1405476c833a2a042e13059 Mon Sep 17 00:00:00 2001 From: Matthew Rodgers Date: Thu, 12 Jun 2025 17:18:56 -0700 Subject: [PATCH 5/5] WC-3584 Move the limited response page to be inlined There's no loader configured for html files by default in wrangler it seems. So rather than mess with loaders or a custom build, inlining this page is easy enough --- .../router-worker/src/limited-response.html | 21 ----------------- .../router-worker/src/worker.ts | 23 ++++++++++++++++++- .../workers-shared/router-worker/types.d.ts | 4 ---- 3 files changed, 22 insertions(+), 26 deletions(-) delete mode 100644 packages/workers-shared/router-worker/src/limited-response.html delete mode 100644 packages/workers-shared/router-worker/types.d.ts diff --git a/packages/workers-shared/router-worker/src/limited-response.html b/packages/workers-shared/router-worker/src/limited-response.html deleted file mode 100644 index 0e851e10c98b..000000000000 --- a/packages/workers-shared/router-worker/src/limited-response.html +++ /dev/null @@ -1,21 +0,0 @@ - - - This website is temporarily limited - - -

This website has been temporarily rate limited

-

- You cannot access this site because the owner has reached their plan - limits. Check back later once traffic has gone down. -

-

- If you are owner of this website, prevent this from happening again by - upgrading your plan on the - Cloudflare Workers dashboard. -

- - diff --git a/packages/workers-shared/router-worker/src/worker.ts b/packages/workers-shared/router-worker/src/worker.ts index 882ae10c5821..7a13778e14af 100644 --- a/packages/workers-shared/router-worker/src/worker.ts +++ b/packages/workers-shared/router-worker/src/worker.ts @@ -7,7 +7,6 @@ import { applyEyeballConfigDefaults, applyRouterConfigDefaults, } from "./configuration"; -import limitedResponse from "./limited-response.html"; import type AssetWorker from "../../asset-worker"; import type { EyeballRouterConfig, @@ -17,6 +16,28 @@ import type { } from "../../utils/types"; import type { ColoMetadata, Environment, ReadyAnalytics } from "./types"; +const limitedResponse = ` + + This website is temporarily limited + + +

This website has been temporarily rate limited

+

+ You cannot access this site because the owner has reached their plan + limits. Check back later once traffic has gone down. +

+

+ If you are owner of this website, prevent this from happening again by + upgrading your plan on the + Cloudflare Workers dashboard. +

+ +`; + export interface Env { ASSET_WORKER: Service; USER_WORKER: Fetcher; diff --git a/packages/workers-shared/router-worker/types.d.ts b/packages/workers-shared/router-worker/types.d.ts deleted file mode 100644 index 6d78d737f920..000000000000 --- a/packages/workers-shared/router-worker/types.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.html" { - const content: string; - export default content; -}