| 1 | import type * as cf from "@cloudflare/workers-types"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import type { RuntimeContext } from "../../RuntimeContext.ts"; |
| 6 | import { |
| 7 | resolveAccessContext, |
| 8 | type WorkerExecutionContextAccess, |
| 9 | } from "./WorkerAccess.ts"; |
| 10 | |
| 11 | export const WorkerTypeId = "Cloudflare.Worker"; |
| 12 | export type WorkerTypeId = typeof WorkerTypeId; |
| 13 | |
| 14 | export class WorkerEnvironment extends Context.Service< |
| 15 | WorkerEnvironment, |
| 16 | Record<string, any> |
| 17 | >()("Cloudflare.Workers.WorkerEnvironment") {} |
| 18 | |
| 19 | export class CachePurgeError extends Data.TaggedError("CachePurgeError")<{ |
| 20 | message: string; |
| 21 | cause?: unknown; |
| 22 | }> {} |
| 23 | |
| 24 | /** |
| 25 | * Effect-native view of the Workers Cache runtime API on the execution |
| 26 | * context (`ctx.cache`). Only available when the Worker has Workers Cache |
| 27 | * enabled (the `cache` prop or `yield* Cloudflare.cache()`). |
| 28 | */ |
| 29 | export interface WorkerExecutionContextCache { |
| 30 | /** |
| 31 | * Purge cached responses by `Cache-Tag`, path prefix, or everything. |
| 32 | */ |
| 33 | purge( |
| 34 | options: cf.CachePurgeOptions, |
| 35 | ): Effect.Effect<cf.CachePurgeResult, CachePurgeError, RuntimeContext>; |
| 36 | } |
| 37 | |
| 38 | export class WorkerExecutionContext extends Context.Service< |
| 39 | WorkerExecutionContext, |
| 40 | { |
| 41 | /** |
| 42 | * Run an Effect in the background without blocking the response, keeping |
| 43 | * the Worker alive until it settles. The Effect runs with the caller's |
| 44 | * full context (services, tracing), and the resulting promise is |
| 45 | * registered with workerd's `ctx.waitUntil`. |
| 46 | */ |
| 47 | waitUntil<A, E, R>( |
| 48 | effect: Effect.Effect<A, E, R>, |
| 49 | ): Effect.Effect<void, never, R | RuntimeContext>; |
| 50 | /** |
| 51 | * Forward the request to the origin if the Worker throws an unhandled |
| 52 | * exception, instead of returning an error page. |
| 53 | */ |
| 54 | passThroughOnException(): Effect.Effect<void, never, RuntimeContext>; |
| 55 | /** |
| 56 | * The Workers Cache runtime API (`ctx.cache`). |
| 57 | */ |
| 58 | readonly cache: WorkerExecutionContextCache; |
| 59 | /** |
| 60 | * The Cloudflare Access context for the current request (`ctx.access`), |
| 61 | * or `undefined` when the request did not pass through Access. Under |
| 62 | * `alchemy dev` the Worker's `dev.access` config simulates it. |
| 63 | */ |
| 64 | readonly access: Effect.Effect< |
| 65 | WorkerExecutionContextAccess | undefined, |
| 66 | never, |
| 67 | RuntimeContext |
| 68 | >; |
| 69 | /** |
| 70 | * The raw workerd ExecutionContext, for interop with async APIs. |
| 71 | */ |
| 72 | readonly raw: cf.ExecutionContext; |
| 73 | } |
| 74 | >()("Cloudflare.Workers.WorkerExecutionContext") {} |
| 75 | |
| 76 | export const fromExecutionContext = ( |
| 77 | ctx: cf.ExecutionContext, |
| 78 | env?: Record<string, unknown>, |
| 79 | ): WorkerExecutionContext["Service"] => ({ |
| 80 | raw: ctx, |
| 81 | access: Effect.sync(() => resolveAccessContext(ctx, env)), |
| 82 | waitUntil: <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 83 | Effect.gen(function* () { |
| 84 | const context = yield* Effect.context<R>(); |
| 85 | // Register the promise with workerd un-awaited — waitUntil extends the |
| 86 | // invocation's lifetime without blocking the response. |
| 87 | yield* Effect.sync(() => |
| 88 | ctx.waitUntil(Effect.runPromise(effect.pipe(Effect.provide(context)))), |
| 89 | ); |
| 90 | }), |
| 91 | passThroughOnException: () => Effect.sync(() => ctx.passThroughOnException()), |
| 92 | cache: { |
| 93 | purge: (options) => |
| 94 | ctx.cache |
| 95 | ? Effect.tryPromise({ |
| 96 | try: () => ctx.cache!.purge(options), |
| 97 | catch: (cause) => |
| 98 | new CachePurgeError({ |
| 99 | message: |
| 100 | cause instanceof Error |
| 101 | ? cause.message |
| 102 | : "Unknown cache purge error", |
| 103 | cause, |
| 104 | }), |
| 105 | }) |
| 106 | : Effect.fail( |
| 107 | new CachePurgeError({ |
| 108 | message: |
| 109 | "ctx.cache is not available — enable Workers Cache on this " + |
| 110 | "Worker (the `cache` prop or `yield* Cloudflare.cache()`) " + |
| 111 | "and note it is not supported in local dev.", |
| 112 | }), |
| 113 | ), |
| 114 | }, |
| 115 | }); |
| 116 | |
| 117 | /** |
| 118 | * A {@link WorkerExecutionContext} whose methods resolve the live per-event |
| 119 | * context from the calling fiber at call time. Provided during the Worker's |
| 120 | * init phase (plan and runtime module init) so the service can be yielded |
| 121 | * and closed over in the top-level closure; every method is colored with |
| 122 | * `RuntimeContext`, so it can only be *run* inside a handler, where the |
| 123 | * bridge provides the real per-event context that these methods defer to. |
| 124 | */ |
| 125 | export const deferredExecutionContext: WorkerExecutionContext["Service"] = { |
| 126 | get raw(): cf.ExecutionContext { |
| 127 | throw new Error( |
| 128 | "WorkerExecutionContext.raw is only available inside a request handler", |
| 129 | ); |
| 130 | }, |
| 131 | waitUntil: <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 132 | liveExecutionContext.pipe( |
| 133 | Effect.flatMap((live) => live.waitUntil(effect)), |
| 134 | ) as Effect.Effect<void, never, R | RuntimeContext>, |
| 135 | passThroughOnException: () => |
| 136 | liveExecutionContext.pipe( |
| 137 | Effect.flatMap((live) => live.passThroughOnException()), |
| 138 | ) as Effect.Effect<void, never, RuntimeContext>, |
| 139 | cache: { |
| 140 | purge: (options) => |
| 141 | liveExecutionContext.pipe( |
| 142 | Effect.flatMap((live) => live.cache.purge(options)), |
| 143 | ) as Effect.Effect<cf.CachePurgeResult, CachePurgeError, RuntimeContext>, |
| 144 | }, |
| 145 | // A getter so this module-level literal doesn't eagerly reference |
| 146 | // `liveExecutionContext` before its declaration below. |
| 147 | get access() { |
| 148 | return liveExecutionContext.pipe( |
| 149 | Effect.flatMap((live) => live.access), |
| 150 | ) as Effect.Effect< |
| 151 | WorkerExecutionContextAccess | undefined, |
| 152 | never, |
| 153 | RuntimeContext |
| 154 | >; |
| 155 | }, |
| 156 | }; |
| 157 | |
| 158 | const liveExecutionContext = WorkerExecutionContext.pipe( |
| 159 | Effect.flatMap((live) => |
| 160 | live === deferredExecutionContext |
| 161 | ? Effect.die( |
| 162 | new Error( |
| 163 | "WorkerExecutionContext can only be used inside a request handler", |
| 164 | ), |
| 165 | ) |
| 166 | : Effect.succeed(live), |
| 167 | ), |
| 168 | ); |
| 169 | |
| 170 | export type WorkerEvent = Exclude< |
| 171 | { |
| 172 | [type in keyof cf.ExportedHandler]: { |
| 173 | kind: "Cloudflare.Workers.WorkerEvent"; |
| 174 | type: type; |
| 175 | input: Parameters<Exclude<cf.ExportedHandler[type], undefined>>[0]; |
| 176 | env: Parameters<Exclude<cf.ExportedHandler[type], undefined>>[1]; |
| 177 | context: Parameters<Exclude<cf.ExportedHandler[type], undefined>>[2]; |
| 178 | }; |
| 179 | }[keyof cf.ExportedHandler], |
| 180 | undefined |
| 181 | >; |
| 182 | |
| 183 | export const isWorkerEvent = (value: any): value is WorkerEvent => |
| 184 | value?.kind === "Cloudflare.Workers.WorkerEvent"; |
| 185 | |
| 186 | export const ExportedHandlerMethods = [ |
| 187 | "fetch", |
| 188 | "tail", |
| 189 | "trace", |
| 190 | "tailStream", |
| 191 | "scheduled", |
| 192 | "test", |
| 193 | "email", |
| 194 | "queue", |
| 195 | ] as const satisfies (keyof cf.ExportedHandler)[]; |