1import type * as cf from "@cloudflare/workers-types";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Namespace from "../../Namespace.ts";
6import { RuntimeContext } from "../../RuntimeContext.ts";
7import type { FunctionContext } from "../../Serverless/Function.ts";
8import { isWorkerEvent, Worker } from "./Worker.ts";
9
10/**
11 * Subscribe to Cloudflare Cron Triggers with an Effect handler.
12 *
13 * A single call wires both pieces of a scheduled Worker:
14 *
15 * - **Deploy-time**: attaches the cron expression to the host Worker's
16 * Cron Triggers.
17 * - **Runtime**: registers a `scheduled` listener that runs your Effect on
18 * each fire. The handler receives Cloudflare's `ScheduledController`,
19 * which has three members:
20 * - `controller.scheduledTime` — the fire time in milliseconds since the
21 * Unix epoch.
22 * - `controller.cron` — the cron expression that fired.
23 * - `controller.noRetry()` — opts the invocation out of Cloudflare's
24 * retry-on-failure. Only meaningful when the scheduled invocation can
25 * actually fail — see the failure & retry section below.
26 *
27 * Each member has its own section below with an Effect example first and
28 * an async example second.
29 *
30 * Requires `CronEventSourceLive` provided on the Worker's Effect.
31 *
32 * **Failure & retry semantics**: a failing handler won't crash the Worker —
33 * the event source catches the failure and moves on. That also means
34 * Cloudflare never observes a failed invocation, so its platform-level
35 * retry (and `controller.noRetry()`) never comes into play here. Express
36 * retry declaratively with `Effect.retry` inside the handler, and log or
37 * report errors if you need visibility into failed runs. In async Workers
38 * the opposite holds: a `scheduled` handler that throws (or rejects) marks
39 * the invocation failed and Cloudflare may retry it — call
40 * `controller.noRetry()` before rethrowing to suppress that.
41 *
42 * Async (non-Effect) Workers don't use `cron` — they attach schedules with
43 * the Worker's `crons` prop and export their own `scheduled` handler from
44 * the entry module (each section below includes the async variant). Pass
45 * `crons: []` to remove all Cron Triggers from a Worker.
46 *
47 *
48 * ### Declare a schedule
49 * **Example:** Effect-native Worker (recommended)
50 * ```typescript
51 * import * as Cloudflare from "alchemy/Cloudflare";
52 * import * as Effect from "effect/Effect";
53 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
54 *
55 * export default Cloudflare.Worker(
56 * "Worker",
57 * { main: import.meta.url },
58 * Effect.gen(function* () {
59 * yield* Cloudflare.Workers.cron("0 12 * * *", () =>
60 * Effect.log("cron fired"),
61 * );
62 *
63 * return {
64 * fetch: Effect.succeed(HttpServerResponse.text("ok")),
65 * };
66 * }).pipe(Effect.provide(Cloudflare.Workers.CronEventSourceLive)),
67 * );
68 * ```
69 *
70 * **Example:** Async Worker — `crons` prop + exported `scheduled` handler
71 * ```typescript
72 * // alchemy.run.ts — attach the cron expressions at deploy time
73 * export const Worker = Cloudflare.Worker("Worker", {
74 * main: "./src/worker.ts",
75 * crons: ["0 12 * * *"],
76 * });
77 *
78 * // src/worker.ts — the entry module handles the fires itself
79 * export default {
80 * async scheduled(controller: ScheduledController) {
81 * console.log("cron fired");
82 * },
83 * };
84 * ```
85 *
86 * ### `controller.scheduledTime` — the fire time
87 * **Example:** Effect: record each fire on a Durable Object
88 * ```typescript
89 * export default class Worker extends Cloudflare.Worker<Worker>()(
90 * "Worker",
91 * { main: import.meta.url },
92 * Effect.gen(function* () {
93 * const counters = yield* CronCounter;
94 *
95 * yield* Cloudflare.Workers.cron("0 * * * *", (controller) =>
96 * counters.getByName("default").record(controller.scheduledTime),
97 * );
98 *
99 * return {
100 * fetch: Effect.gen(function* () {
101 * const { times } = yield* counters.getByName("default").snapshot();
102 * return yield* HttpServerResponse.json({ times });
103 * }),
104 * };
105 * }).pipe(Effect.provide(Cloudflare.Workers.CronEventSourceLive)),
106 * ) {}
107 * ```
108 *
109 * **Example:** Async: use the fire time as an idempotency key
110 * ```typescript
111 * // scheduledTime is the time the fire was *scheduled* for (not when it
112 * // ran), so it is stable across retries of the same fire — a natural
113 * // idempotency key for at-most-once side effects.
114 * export default {
115 * async scheduled(controller: ScheduledController, env: WorkerEnv) {
116 * const key = `run:${controller.scheduledTime}`;
117 * if (await env.RUNS.get(key)) return;
118 * await syncFeeds();
119 * await env.RUNS.put(key, "done");
120 * },
121 * };
122 * ```
123 *
124 * ### `controller.cron` — dispatch multiple schedules
125 * **Example:** Effect: one handler per expression
126 * ```typescript
127 * // Each handler only runs for fires of its own expression — the listener
128 * // checks controller.cron, so a midnight fire never runs the hourly handler.
129 * yield* Cloudflare.Workers.cron("0 * * * *", () => syncFeeds);
130 * yield* Cloudflare.Workers.cron("0 0 * * *", () => purgeExpired);
131 * ```
132 *
133 * **Example:** Async: switch on `controller.cron`
134 * ```typescript
135 * export const Worker = Cloudflare.Worker("Worker", {
136 * main: "./src/worker.ts",
137 * crons: ["0 * * * *", "0 0 * * *"],
138 * });
139 *
140 * // src/worker.ts — one scheduled handler receives every fire
141 * export default {
142 * async scheduled(controller: ScheduledController) {
143 * switch (controller.cron) {
144 * case "0 * * * *":
145 * await syncFeeds();
146 * break;
147 * case "0 0 * * *":
148 * await purgeExpired();
149 * break;
150 * }
151 * },
152 * };
153 * ```
154 *
155 * ### `controller.noRetry()` — failure & retry control
156 * **Example:** Effect: bound retries with `Effect.retry`
157 * ```typescript
158 * import * as Schedule from "effect/Schedule";
159 *
160 * // The event source reports the invocation as successful even when the
161 * // handler fails, so Cloudflare's platform retry rarely engages for Effect
162 * // handlers — Effect.retry is the primary retry control. Calling
163 * // controller.noRetry() once retries are exhausted defensively covers
164 * // anything that can still mark the invocation failed (e.g. a failing
165 * // waitUntil task).
166 * yield* Cloudflare.Workers.cron("0 * * * *", (controller) =>
167 * syncFeeds.pipe(
168 * Effect.retry({ schedule: Schedule.exponential("1 second"), times: 3 }),
169 * Effect.tapError((error) =>
170 * Effect.logError("syncFeeds failed permanently", error).pipe(
171 * Effect.andThen(Effect.sync(() => controller.noRetry())),
172 * ),
173 * ),
174 * ),
175 * );
176 * ```
177 *
178 * **Example:** Async: suppress retry for permanent failures
179 * ```typescript
180 * // src/worker.ts — a thrown error marks the invocation failed and
181 * // Cloudflare may retry it; noRetry() opts this fire out of that.
182 * export default {
183 * async scheduled(controller: ScheduledController) {
184 * try {
185 * await syncFeeds();
186 * } catch (error) {
187 * if (isPermanentFailure(error)) {
188 * controller.noRetry();
189 * }
190 * throw error; // still recorded as a failed invocation
191 * }
192 * },
193 * };
194 * ```
195 *
196 * @see https://developers.cloudflare.com/workers/configuration/cron-triggers/
197 *
198 * @binding
199 * @product Workers
200 * @category Workers & Compute
201 */
202export const cron = <Req = never>(
203 expression: string,
204 process: (
205 controller: cf.ScheduledController,
206 ) => Effect.Effect<void, unknown, Req>,
207): Effect.Effect<void, never, CronEventSource | Exclude<Req, RuntimeContext>> =>
208 CronEventSource.use((source) => source(expression, process));
209
210export type CronEventSourceService = <Req = never>(
211 expression: string,
212 process: (
213 controller: cf.ScheduledController,
214 ) => Effect.Effect<void, unknown, Req>,
215) => Effect.Effect<void, never, Exclude<Req, RuntimeContext>>;
216
217export class CronEventSource extends Context.Service<
218 CronEventSource,
219 CronEventSourceService
220>()("Cloudflare.Workers.CronEventSource") {}
221
222export const CronEventSourceLive = Layer.effect(
223 CronEventSource,
224 Effect.gen(function* () {
225 const host = yield* Worker;
226 return Effect.fn(function* <Req>(
227 expression: string,
228 process: (
229 controller: cf.ScheduledController,
230 ) => Effect.Effect<void, unknown, Req>,
231 ) {
232 // Deploy-time: attach the cron expression to the host Worker. Skipped once
233 // running inside the deployed Worker (the global guard), where the only
234 // work is registering the runtime scheduled handler below. Namespaced
235 // under the host so logical identity matches the previous Binding.Policy.
236 if (!globalThis.__ALCHEMY_RUNTIME__) {
237 yield* Namespace.push(
238 host.LogicalId,
239 host.bind(`Cron(${expression})`, {
240 crons: [expression],
241 }),
242 );
243 }
244
245 const ctx = (yield* RuntimeContext) as unknown as FunctionContext;
246 yield* ctx.listen<void, Req>((event) => {
247 if (!isWorkerEvent(event) || event.type !== "scheduled") return;
248
249 const controller = event.input as cf.ScheduledController;
250 if (controller.cron !== expression) return;
251
252 return process(controller).pipe(Effect.catchCause(() => Effect.void));
253 });
254 }) as CronEventSourceService;
255 }),
256);