| 1 | import type * as cf from "@cloudflare/workers-types"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Namespace from "../../Namespace.ts"; |
| 6 | import { RuntimeContext } from "../../RuntimeContext.ts"; |
| 7 | import type { FunctionContext } from "../../Serverless/Function.ts"; |
| 8 | import { 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 | */ |
| 202 | export 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 | |
| 210 | export 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 | |
| 217 | export class CronEventSource extends Context.Service< |
| 218 | CronEventSource, |
| 219 | CronEventSourceService |
| 220 | >()("Cloudflare.Workers.CronEventSource") {} |
| 221 | |
| 222 | export 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 | ); |