import { WorkerEntrypoint as CloudflareWorkerEntrypoint } from "cloudflare:workers"; import { Cause, Context, Effect, Exit, Layer, ManagedRuntime, type Scope } from "effect"; import { HttpServerRequest } from "effect/unstable/http"; import { WorkerEnvironment, type WorkerEnv } from "./Environment"; /** * Access to Cloudflare's native `ExecutionContext`. */ export class ExecutionContext extends Context.Service< ExecutionContext, globalThis.ExecutionContext >()("effect-cf/ExecutionContext") {} /** * Options for {@link WorkerContextService.waitUntil}. */ export interface WorkerContextWaitUntilOptions { /** Custom failure handler for the background effect. */ readonly onFailure?: (cause: Cause.Cause) => Effect.Effect; } /** * Effect wrapper around `ExecutionContext` background APIs. */ export interface WorkerContextService { readonly raw: globalThis.ExecutionContext; waitUntil( effect: Effect.Effect, options?: WorkerContextWaitUntilOptions, ): Effect.Effect; readonly passThroughOnException: Effect.Effect; } /** * Service used inside handlers to schedule background work via `waitUntil`. * * @example * ```ts * const handler = Effect.gen(function* () { * const ctx = yield* WorkerContext; * yield* ctx.waitUntil(Effect.log("flush analytics")); * return new Response("ok"); * }); * ``` */ export class WorkerContext extends Context.Service()( "effect-cf/WorkerContext", ) {} type RunWaitUntilEffect = ( effect: Effect.Effect, ) => Promise>; const fromExecutionContext = ( ctx: globalThis.ExecutionContext, runPromiseExit: RunWaitUntilEffect, ): WorkerContextService => ({ raw: ctx, waitUntil: ( effect: Effect.Effect, options?: WorkerContextWaitUntilOptions, ) => Effect.context().pipe( Effect.flatMap((context) => Effect.sync(() => { const observed = Effect.exit(effect).pipe( Effect.flatMap((exit) => { if (Exit.isSuccess(exit)) { return Effect.void; } const handleFailure = options?.onFailure?.(exit.cause) ?? Effect.logError("WorkerContext.waitUntil failed", Cause.pretty(exit.cause)); return handleFailure.pipe( Effect.catchCause((handlerCause) => Effect.logError( "WorkerContext.waitUntil failure handler failed", Cause.pretty(exit.cause), Cause.pretty(handlerCause), ), ), ); }), ); ctx.waitUntil( runPromiseExit(Effect.scoped(Effect.provideContext(observed, context))).then((exit) => { if (Exit.isFailure(exit)) { console.error( "WorkerContext.waitUntil failure handler failed", Cause.pretty(exit.cause), ); } }), ); }), ), ), passThroughOnException: Effect.sync(() => ctx.passThroughOnException()), }); /** * Access to the incoming `Request` currently handled by a worker or Durable Object fetch. */ export class NativeRequest extends Context.Service()( "effect-cf/NativeRequest", ) {} /** * Returns `true` when the request is a websocket upgrade request. */ export const isWebSocketUpgrade = (request: Request): boolean => request.headers.get("Upgrade")?.toLowerCase() === "websocket"; type RequestServices = ExecutionContext | WorkerContext | WorkerEnvironment; type HandlerContext = | ExecutionContext | WorkerContext | WorkerEnvironment | NativeRequest | HttpServerRequest.HttpServerRequest | ROut | Scope.Scope; type RuntimeContext = RequestServices | ROut; type RpcContext = RuntimeContext | Scope.Scope; const RunSymbol = Symbol.for("effect-cf/Worker/run"); /** * Effect type for `fetch` handlers executed by {@link make}. */ export type WorkerHandler = Effect.Effect>; /** * Effect type for worker RPC handlers. */ export type WorkerRpcHandler = Effect.Effect>; /** * Shape of worker RPC handlers passed to {@link make}. */ export type WorkerRpc = Record) => WorkerRpcHandler>; export type WorkerRpcShape, ROut> = { readonly [Key in keyof Rpc]: Rpc[Key] extends ( ...args: infer Args ) => Effect.Effect> ? (...args: Args) => Promise : never; }; export type RpcHandlers = { readonly [Key in keyof Api as Key extends keyof CloudflareWorkerEntrypoint ? never : Key extends string ? [Api[Key]] extends [never] ? never : Api[Key] extends (...args: Array) => Promise ? Key : never : never]: Api[Key] extends (...args: infer Args) => Promise ? (...args: Args) => WorkerRpcHandler : never; }; /** * Options for creating a worker class with Effect handlers. */ export interface WorkerOptions> { /** Main request handler. */ readonly fetch: Effect.Effect>; /** Optional RPC methods exposed as class instance methods. */ readonly rpc?: Rpc; } /** * Cloudflare `WorkerEntrypoint` constructor produced by {@link make}. */ export type WorkerClass, ROut> = new ( ctx: globalThis.ExecutionContext, env: WorkerEnv, ) => CloudflareWorkerEntrypoint & WorkerRpcShape; /** * Creates a Cloudflare worker class backed by a single managed Effect runtime. * * @example * ```ts * const Worker = Entry.make(Layer.empty, { * fetch: Effect.succeed(new Response("ok")), * }); * ``` */ export const make = = Record>( layer: Layer.Layer, options: WorkerOptions, ): WorkerClass => { class EffectWorker extends CloudflareWorkerEntrypoint { readonly runtime: ManagedRuntime.ManagedRuntime, LayerError>; constructor(ctx: globalThis.ExecutionContext, env: WorkerEnv) { super(ctx, env); let runWaitUntilEffect: RunWaitUntilEffect = () => Promise.resolve(Exit.die(new Error("WorkerContext runtime is not initialized"))); const services = Layer.mergeAll( Layer.succeed(ExecutionContext, ctx), Layer.succeed( WorkerContext, fromExecutionContext(ctx, (effect) => runWaitUntilEffect(effect)), ), Layer.succeed(WorkerEnvironment, env), ); const runtimeLayer = layer.pipe(Layer.provideMerge(services)) as Layer.Layer< RuntimeContext, LayerError, never >; this.runtime = ManagedRuntime.make(runtimeLayer); runWaitUntilEffect = (effect: Effect.Effect) => this.runtime.runPromiseExit(effect as Effect.Effect>); } [RunSymbol](effect: Effect.Effect | Scope.Scope>): Promise { return this.runtime.runPromise(Effect.scoped(effect)); } fetch(request: Request): Promise { return this[RunSymbol]( options.fetch.pipe( Effect.provideService(NativeRequest, request), Effect.provideService( HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request), ), ), ); } } for (const [key, method] of Object.entries(options.rpc ?? {})) { Object.defineProperty(EffectWorker.prototype, key, { enumerable: true, value(this: EffectWorker, ...args: Array) { return this[RunSymbol](Effect.suspend(() => method(...args))); }, }); } return EffectWorker as WorkerClass; };