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
155 changes: 131 additions & 24 deletions packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import * as durableObjectsApi from "@distilled.cloud/cloudflare/durable-objects"
import * as workers from "@distilled.cloud/cloudflare/workers";
import * as wfp from "@distilled.cloud/cloudflare/workers-for-platforms";
import * as zones from "@distilled.cloud/cloudflare/zones";
import * as Config from "effect/Config";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Predicate from "effect/Predicate";
import * as Redacted from "effect/Redacted";
Expand All @@ -19,6 +21,7 @@ import * as ProviderLayer from "../../Local/ProviderLayer.ts";
import * as Provider from "../../Provider.ts";
import { type ResourceBinding } from "../../Resource.ts";
import { Stack } from "../../Stack.ts";
import { cachedFunction } from "../../Util/cached-function.ts";
import { sha256Object } from "../../Util/sha256.ts";
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
import { CloudflareLogs } from "../Logs.ts";
Expand Down Expand Up @@ -290,6 +293,60 @@ export const normalizeStateDomains = (
return typeof hostname === "string" ? [`https://${hostname}`] : [];
});

/**
* Custom domains Alchemy is responsible for on this Worker — either declared
* on props (`domain`) or already persisted as non-`workers.dev` URLs in state.
* Used by `read` to skip `listDomains` when the surface is unmanaged (#926).
*
* @internal exported for unit testing.
*/
export const shouldObserveWorkerDomains = (
olds: Pick<WorkerProps, "domain"> | undefined,
output: Pick<Worker["Attributes"], "domains"> | undefined,
): boolean =>
olds?.domain !== undefined ||
normalizeStateDomains(output?.domains).some(
(url) => !url.endsWith(".workers.dev"),
);

/**
* Zone routes Alchemy is responsible for on this Worker. Used by `read` to
* skip account-wide zone/route fan-out when the surface is unmanaged (#926).
*
* @internal exported for unit testing.
*/
export const shouldObserveWorkerRoutes = (
olds: Pick<WorkerProps, "routes"> | undefined,
output: Pick<Worker["Attributes"], "routes"> | undefined,
): boolean => olds?.routes !== undefined || (output?.routes?.length ?? 0) > 0;

/**
* Cron triggers Alchemy is responsible for on this Worker. Used by `read` to
* skip `getScriptSchedule` when the surface is unmanaged (#926). Effect-native
* `cron()` bindings persist into `output.crons` after the first reconcile, so
* subsequent reads still observe them.
*
* @internal exported for unit testing.
*/
export const shouldObserveWorkerCrons = (
olds: Pick<WorkerProps, "crons"> | undefined,
output: Pick<Worker["Attributes"], "crons"> | undefined,
): boolean => olds?.crons !== undefined || (output?.crons?.length ?? 0) > 0;

/**
* Optional override for the account's stable `workers.dev` subdomain
* (`<subdomain>` in `https://<script>.<subdomain>.workers.dev`). When set,
* Worker URL construction skips `GET /accounts/{id}/workers/subdomain`.
*/
const CLOUDFLARE_WORKERS_SUBDOMAIN = Config.string(
"CLOUDFLARE_WORKERS_SUBDOMAIN",
).pipe(
Config.map((value) => value.trim()),
Config.option,
);

const WORKERS_SUBDOMAIN_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;

type MetadataHashValue =
| string
| number
Expand Down Expand Up @@ -459,12 +516,29 @@ export const LiveWorkerProvider = () =>
// const listZones = yield* zones.listZones;
const telemetry = yield* CloudflareLogs;

const getAccountSubdomain = (accountId: string) =>
workers
.getSubdomain({
accountId,
})
.pipe(Effect.map((result) => result.subdomain));
// Account subdomain is invariant for the life of a provider layer —
// memoize so N Workers (or repeated plan/deploy reads) don't each hit
// GET /accounts/{id}/workers/subdomain and trip Cloudflare's 429/971
// throttle (#926). `CLOUDFLARE_WORKERS_SUBDOMAIN` bypasses the lookup
// entirely when the operator already knows the value.
const getAccountSubdomain = yield* cachedFunction((accountId: string) =>
Effect.gen(function* () {
const configured = yield* CLOUDFLARE_WORKERS_SUBDOMAIN;
if (Option.isSome(configured) && configured.value !== "") {
if (!WORKERS_SUBDOMAIN_PATTERN.test(configured.value)) {
return yield* Effect.die(
new Error(
`Invalid CLOUDFLARE_WORKERS_SUBDOMAIN "${configured.value}": ` +
"expected a workers.dev subdomain label (lowercase letters, digits, hyphens)",
),
);
}
return configured.value;
}
const result = yield* workers.getSubdomain({ accountId });
return result.subdomain;
}),
);

// Toggle the workers.dev subdomain via `POST /subdomain` with
// `enabled: true | false`. Mirrors the upstream Alchemy
Expand Down Expand Up @@ -2011,13 +2085,22 @@ export const LiveWorkerProvider = () =>
);
}
const desiredDomains = normalizeDomains(news.domain);
const previousDomains = output?.domains ?? [];
if (desiredDomains.length > 0 || previousDomains.length > 0) {
const previousCustomDomains = normalizeStateDomains(
output?.domains,
).filter((url) => !url.endsWith(".workers.dev"));
// Skip listDomains when Alchemy has never managed custom domains for
// this Worker — otherwise every deploy pays for an unused account call
// (#926). Empty `domain: []` still reconciles so deletions converge.
const reconcileCustomDomains =
news.domain !== undefined || previousCustomDomains.length > 0;
if (reconcileCustomDomains) {
yield* session.note(
`Reconciling custom domains (${desiredDomains.length}) ...`,
);
}
const reconciled = yield* reconcileDomains(name, desiredDomains);
const reconciled = reconcileCustomDomains
? yield* reconcileDomains(name, desiredDomains)
: [];
const workersDevUrl =
news.url !== false
? `https://${name}.${yield* getAccountSubdomain(accountId)}.workers.dev`
Expand All @@ -2038,12 +2121,17 @@ export const LiveWorkerProvider = () =>
desiredRoutes,
previousRoutes,
);
const crons = yield* reconcileCrons(
name,
normalizeCrons([...getCronBindings(bindings), ...(news.crons ?? [])]),
output?.crons ?? [],
session,
);
const desiredCrons = normalizeCrons([
...getCronBindings(bindings),
...(news.crons ?? []),
]);
const previousCrons = output?.crons ?? [];
// Same gating as read: skip getScriptSchedule when neither props nor
// prior state indicate cron management (#926).
const crons =
desiredCrons.length > 0 || previousCrons.length > 0
? yield* reconcileCrons(name, desiredCrons, previousCrons, session)
: [];
return {
workerId: worker.id ?? name,
workerName: name,
Expand Down Expand Up @@ -2622,6 +2710,17 @@ export const LiveWorkerProvider = () =>
// `WorkerNotFound` if the script doesn't exist, which the
// surrounding `Effect.catchTag` turns into `undefined` — that's
// all the existence check we need.
//
// Domain / route / cron observation is gated on whether Alchemy
// manages that surface for this Worker (#926). A plain workers.dev
// Worker otherwise fans out through listDomains + every account
// zone's routes + getScriptSchedule on every plan/deploy read,
// which readily trips Cloudflare's account-level 429/971 throttle.
// Empty-array props (`domain: []`, `routes: []`, `crons: []`) still
// observe so we can detect drift and converge deletions.
const observeDomains = shouldObserveWorkerDomains(olds, output);
const observeRoutes = shouldObserveWorkerRoutes(olds, output);
const observeCrons = shouldObserveWorkerCrons(olds, output);
const [subdomain, settings, domainsList, routesList] =
yield* Effect.all(
[
Expand All @@ -2633,15 +2732,23 @@ export const LiveWorkerProvider = () =>
accountId,
scriptName: workerName,
}),
workers
.listDomains({
accountId,
service: workerName,
})
.pipe(Effect.map((r) => r.result ?? [])),
readWorkerRoutes(workerName),
observeDomains
? workers
.listDomains({
accountId,
service: workerName,
})
.pipe(Effect.map((r) => r.result ?? []))
: Effect.succeed(
[] as workers.ListDomainsResponse["result"],
),
observeRoutes
? readWorkerRoutes(workerName)
: Effect.succeed([] as Worker["Attributes"]["routes"]),
],
{ concurrency: "unbounded" },
// Bound concurrency so a single Worker read doesn't stampede
// the account alongside every other Worker's lifecycle calls.
{ concurrency: 1 },
);
// Preserve the order the user provided in `olds.domain`. The
// Cloudflare API returns domains in non-deterministic order,
Expand All @@ -2667,7 +2774,7 @@ export const LiveWorkerProvider = () =>
...orderedHostnames.map((h) => `https://${h}`),
...(workersDevUrl ? [workersDevUrl] : []),
];
const crons = yield* getWorkerCrons(workerName);
const crons = observeCrons ? yield* getWorkerCrons(workerName) : [];
yield* Effect.logInfo(
`Cloudflare Worker read: found ${workerName}`,
);
Expand Down
89 changes: 89 additions & 0 deletions packages/alchemy/test/Cloudflare/Workers/WorkerProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {
encodeDurableObjectTags,
getDurableObjectTagMap,
normalizeStateDomains,
shouldObserveWorkerCrons,
shouldObserveWorkerDomains,
shouldObserveWorkerRoutes,
} from "@/Cloudflare/Workers/WorkerProvider";
import { describe, expect, test } from "alchemy-test";

Expand Down Expand Up @@ -186,4 +189,90 @@ describe("WorkerProvider", () => {
expect(getDurableObjectTagMap(["alchemy:stack:app", "user"])).toEqual({});
});
});

// Worker read used to always fan out listDomains + account-wide route
// discovery + getScriptSchedule, even for a plain workers.dev Worker. That
// stampeded GET /accounts/{id}/workers/subdomain neighbors into 429/code 971
// (#926). These helpers gate those observations on whether Alchemy manages
// the surface.
describe("shouldObserveWorkerDomains", () => {
test("skips when neither props nor state manage custom domains", () => {
expect(
shouldObserveWorkerDomains(
{},
{
domains: ["https://my-worker.acct.workers.dev"],
},
),
).toBe(false);
expect(shouldObserveWorkerDomains(undefined, undefined)).toBe(false);
});

test("observes when domain prop is present, including empty array", () => {
expect(shouldObserveWorkerDomains({ domain: [] }, undefined)).toBe(true);
expect(
shouldObserveWorkerDomains({ domain: "app.example.com" }, undefined),
).toBe(true);
});

test("observes when prior state has non-workers.dev domains", () => {
expect(
shouldObserveWorkerDomains(
{},
{
domains: [
"https://app.example.com",
"https://my-worker.acct.workers.dev",
],
},
),
).toBe(true);
});
});

describe("shouldObserveWorkerRoutes", () => {
test("skips when neither props nor state manage routes", () => {
expect(shouldObserveWorkerRoutes({}, { routes: [] })).toBe(false);
expect(shouldObserveWorkerRoutes(undefined, undefined)).toBe(false);
});

test("observes when routes prop is present, including empty array", () => {
expect(shouldObserveWorkerRoutes({ routes: [] }, undefined)).toBe(true);
expect(
shouldObserveWorkerRoutes(
{ routes: [{ pattern: "example.com/*" }] },
undefined,
),
).toBe(true);
});

test("observes when prior state has routes", () => {
expect(
shouldObserveWorkerRoutes(
{},
{
routes: [{ id: "r1", pattern: "example.com/*", zoneId: "z1" }],
},
),
).toBe(true);
});
});

describe("shouldObserveWorkerCrons", () => {
test("skips when neither props nor state manage crons", () => {
expect(shouldObserveWorkerCrons({}, { crons: [] })).toBe(false);
expect(shouldObserveWorkerCrons(undefined, undefined)).toBe(false);
});

test("observes when crons prop is present, including empty array", () => {
expect(shouldObserveWorkerCrons({ crons: [] }, undefined)).toBe(true);
expect(
shouldObserveWorkerCrons({ crons: ["0 * * * *"] }, undefined),
).toBe(true);
});

test("observes when prior state has crons (e.g. Effect-native cron())", () => {
expect(shouldObserveWorkerCrons({}, { crons: ["0 * * * *"] })).toBe(true);
});
});
});
Loading