import { Context, Data, Effect, type Scope } from "effect"; import * as Binding from "./Binding"; import * as CloudflareRpc from "./Rpc"; import * as RpcDefinition from "./RpcDefinition"; import type * as WorkerDefinition from "./WorkerDefinition"; import * as RpcInvocation from "./internal/RpcInvocation"; const TypeId = "effect-cf/ServiceBinding" as const; const expectedServiceBinding = "Worker service binding with fetch()"; /** * Minimum shape for a Cloudflare service binding. */ export interface ServiceFetcher { fetch(input: RequestInfo | URL, init?: RequestInit): Promise; } type RpcClient = { readonly [Key in keyof Api as Key extends string ? Api[Key] extends (...args: Array) => unknown ? Key : never : never]: Api[Key]; }; type ReservedMethodName = WorkerDefinition.ReservedMethodName | "fetch"; /** * Native Cloudflare service object including optional RPC methods. */ export type ServiceBindingClient = ServiceFetcher & RpcClient>; type ApiFromDefinition = Definition extends WorkerDefinition.Definition.Any ? WorkerDefinition.ServerApi : never; type ApiOrDefinition = [Api] extends [never] ? ApiFromDefinition : Api; /** * Binding metadata used to create an Effect service from a Worker binding. */ export interface ServiceBindingDefinition< Definition extends WorkerDefinition.Definition.Any | undefined = undefined, > { /** Binding name as configured in `wrangler.jsonc`. */ readonly binding: string; /** Optional RPC schema used for argument/result encoding. */ readonly definition?: Definition; } /** * Failure raised when calling `fetch` on a service binding. */ export class ServiceBindingFetchError extends Data.TaggedError("ServiceBindingFetchError")<{ readonly binding: string; readonly cause: unknown; }> {} /** * Failure raised when invoking an RPC method on a service binding. */ export class ServiceBindingRpcError extends Data.TaggedError("ServiceBindingRpcError")<{ readonly binding: string; readonly method: string; readonly cause: unknown; }> {} type ServiceMethodKey = RpcInvocation.AsyncMethodKey; type ServiceMethodArgs = RpcInvocation.AsyncMethodArgs; type ServiceMethodSuccess = RpcInvocation.AsyncMethodSuccess< Api, Method >; type ServiceMethodCloudflareReturn< Api, Method extends keyof Api, > = RpcInvocation.AsyncMethodCloudflareReturn; type ServiceCall = >( method: Method, ...args: ServiceMethodArgs ) => Effect.Effect, ServiceBindingRpcError, R>; type DefinitionDirectMethods = { readonly [Method in RpcDefinition.Definition.MethodNames]: ( ...args: RpcDefinition.Method.Args ) => Effect.Effect< RpcDefinition.Method.Success, ServiceBindingRpcError, R >; }; type DirectMethods = Definition extends WorkerDefinition.Definition.Any ? DefinitionDirectMethods : {}; export type ServiceBindingEffectClient< Api extends object, Definition extends WorkerDefinition.Definition.Any | undefined = undefined, > = DirectMethods & { /** * Forwards an HTTP request to the bound Worker service. * * Use this when the service binding is acting as an HTTP origin rather than a * Cloudflare RPC target. * * @example * ```ts * import { Effect } from "effect"; * * const program = Effect.gen(function* () { * const api = yield* ApiWorker; * return yield* api.fetch(new Request("https://internal.example/users")); * }); * ``` */ readonly fetch: ( input: RequestInfo | URL, init?: RequestInit, ) => Effect.Effect; /** * Invokes a Worker RPC method and returns Cloudflare's raw RPC result. * * This preserves Cloudflare RPC behavior such as promise-like pipelining and * transferable / disposable result objects. It does not resolve the returned * promise-like value and it does not decode definition-backed success schemas. * * Most application code should use {@link call} instead. * * @example * ```ts * import { Effect } from "effect"; * * const program = Effect.gen(function* () { * const counter = yield* CounterService; * * const result = yield* counter.rpc("increment", 41); * const value = yield* Effect.promise(() => result); * * return value; * }); * ``` */ readonly rpc: >( method: Method, ...args: ServiceMethodArgs ) => Effect.Effect, ServiceBindingRpcError>; /** * Invokes a Worker RPC method, resolves Cloudflare's RPC result, and decodes * the success value when the binding was created from a definition. * * This is the normal choice when application code wants the final typed value. * * @example * ```ts * import { Effect } from "effect"; * * const program = Effect.gen(function* () { * const counter = yield* CounterService; * const value = yield* counter.call("increment", 41); * * return value; * }); * ``` */ readonly call: >( method: Method, ...args: ServiceMethodArgs ) => Effect.Effect, ServiceBindingRpcError>; /** * Invokes a Worker RPC method in the current `Scope`, resolves Cloudflare's RPC * result, decodes definition-backed success values, and disposes the resolved * result when the scope closes if it implements `Symbol.dispose`. * * Use this for RPC methods that return Cloudflare RPC resources or other * disposable objects whose lifetime should be tied to an Effect scope. * * @example * ```ts * import { Effect } from "effect"; * * const program = Effect.scoped( * Effect.gen(function* () { * const files = yield* FileService; * const handle = yield* files.scopedCall("open", "report.csv"); * * return yield* handle.read(); * }), * ); * ``` */ readonly scopedCall: >( method: Method, ...args: ServiceMethodArgs ) => Effect.Effect>, unknown, Scope.Scope>; }; export type ServiceBindingStaticClient< R, Api extends object, Definition extends WorkerDefinition.Definition.Any | undefined = undefined, > = DirectMethods & { readonly fetch: ( input: RequestInfo | URL, init?: RequestInit, ) => Effect.Effect; readonly rpc: >( method: Method, ...args: ServiceMethodArgs ) => Effect.Effect, ServiceBindingRpcError, R>; readonly call: >( method: Method, ...args: ServiceMethodArgs ) => Effect.Effect, ServiceBindingRpcError, R>; readonly scopedCall: >( method: Method, ...args: ServiceMethodArgs ) => Effect.Effect>, unknown, Scope.Scope | R>; }; export const isServiceBindingClient = ( value: unknown, ): value is ServiceBindingClient => typeof value === "object" && value !== null && typeof Reflect.get(value, "fetch") === "function"; export const makeClient = < Api extends object, const Definition extends WorkerDefinition.Definition.Any | undefined = undefined, >( definition: ServiceBindingDefinition, ): ((service: ServiceBindingClient) => ServiceBindingEffectClient) => { return (service: ServiceBindingClient) => { const fetch = (input: RequestInfo | URL, init?: RequestInit) => Effect.tryPromise({ try: () => service.fetch(input, init), catch: (cause) => new ServiceBindingFetchError({ binding: definition.binding, cause }), }); const rpc = >( method: Method, ...args: ServiceMethodArgs ) => Effect.gen(function* () { const methodName = String(method); const encodedArgs = definition.definition === undefined ? args : yield* RpcDefinition.encodeArgs( definition.definition, methodName as RpcDefinition.Definition.MethodNames>, args as never, ).pipe( Effect.mapError( (cause) => new ServiceBindingRpcError({ binding: definition.binding, method: methodName, cause, }), ), ); return yield* RpcInvocation.invokeRpcMethod( service, method, encodedArgs as ServiceMethodArgs, (cause) => new ServiceBindingRpcError({ binding: definition.binding, method: methodName, cause, }), ); }); const decodeSuccess = >( methodName: string, value: Awaited>, ) => Effect.gen(function* () { if (definition.definition === undefined) { return value as ServiceMethodSuccess; } const decoded = yield* RpcDefinition.decodeSuccess( definition.definition, methodName as RpcDefinition.Definition.MethodNames>, value, ).pipe( Effect.mapError( (cause) => new ServiceBindingRpcError({ binding: definition.binding, method: methodName, cause, }), ), ); return decoded as ServiceMethodSuccess; }); const call = >( method: Method, ...args: ServiceMethodArgs ) => Effect.gen(function* () { const methodName = String(method); const value = yield* CloudflareRpc.resolve(yield* rpc(method, ...args)).pipe( Effect.mapError( (cause) => new ServiceBindingRpcError({ binding: definition.binding, method: methodName, cause, }), ), ); return yield* decodeSuccess(methodName, value); }); const scopedCall = >( method: Method, ...args: ServiceMethodArgs ) => Effect.gen(function* () { const methodName = String(method); const result = yield* rpc(method, ...args); const value = yield* CloudflareRpc.scoped(result); return yield* decodeSuccess(methodName, value); }); const directMethods = makeDirectMethods(definition.definition, call); return Object.assign(directMethods, { fetch, rpc, call, scopedCall, }) as ServiceBindingEffectClient; }; }; export const layer = < Self, Api extends object, const Definition extends WorkerDefinition.Definition.Any | undefined = undefined, >( tag: Context.Service>, definition: ServiceBindingDefinition, ) => Binding.layer( tag, definition.binding, (value): value is ServiceBindingClient => isServiceBindingClient(value), makeClient(definition), { expected: expectedServiceBinding }, ); /** * Creates a typed Effect service for a Worker service binding. * * Returned value includes: * - a Context tag for dependency injection * - `fetch(...)` for raw HTTP forwarding * - `rpc(...)` for raw Cloudflare RPC results * - `call(...)` for resolved and decoded RPC results * - `scopedCall(...)` for scoped and decoded disposable RPC results * - direct RPC methods when `definition` is provided * * @example * ```ts * const Counter = WorkerDefinition.make("Counter", { * increment: WorkerDefinition.method({ * args: [Schema.Number], * success: Schema.Number, * }), * }); * * const CounterLive = Counter.layer({ binding: "COUNTER" }); * * const program = Effect.gen(function* () { * const counter = yield* Counter; * const next = yield* counter.increment(1); * return next; * }); * ``` */ export const Service = () => < Id extends string, const Definition extends WorkerDefinition.Definition.Any | undefined = undefined, >( id: Id, definition: ServiceBindingDefinition, ) => { type ServiceApi = ApiOrDefinition; const tag = Binding.Service()( id, definition.binding, (value): value is ServiceBindingClient => isServiceBindingClient(value), makeClient(definition), ); const fetch = (input: RequestInfo | URL, init?: RequestInit) => Effect.gen(function* () { const service = yield* tag; return yield* service.fetch(input, init); }); const rpc = >( method: Method, ...args: ServiceMethodArgs ) => Effect.gen(function* () { const service = yield* tag; return yield* service.rpc(method, ...args); }); const call = >( method: Method, ...args: ServiceMethodArgs ) => Effect.gen(function* () { const service = yield* tag; return yield* service.call(method, ...args); }); const scopedCall = >( method: Method, ...args: ServiceMethodArgs ) => Effect.gen(function* () { const service = yield* tag; return yield* service.scopedCall(method, ...args); }); const directMethods = makeDirectMethods( definition.definition, call, ); return Object.assign(tag, directMethods, { [TypeId]: TypeId, definition, fetch, rpc, call, scopedCall, }) as typeof tag & DirectMethods & { readonly [TypeId]: typeof TypeId; readonly definition: ServiceBindingDefinition; readonly fetch: typeof fetch; readonly rpc: typeof rpc; readonly call: typeof call; readonly scopedCall: typeof scopedCall; }; }; export const makeDirectMethods = < R, Api, Definition extends WorkerDefinition.Definition.Any | undefined, >( rpcDefinition: Definition | undefined, call: ServiceCall, ): DirectMethods => { const methods = {} as Record; if (rpcDefinition !== undefined) { for (const methodName of Object.keys(rpcDefinition.methods)) { methods[methodName] = (...args: Array) => (call as (method: string, ...args: Array) => unknown)(methodName, ...args); } } return methods as DirectMethods; };