effect-cf 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ import { Data, Effect } from "effect";
2
+
3
+ import * as Binding from "./Binding";
4
+ import * as CloudflareRpc from "./Rpc";
5
+ import * as RpcDefinition from "./RpcDefinition";
6
+ import type * as WorkerDefinition from "./WorkerDefinition";
7
+ import * as RpcInvocation from "./internal/RpcInvocation";
8
+
9
+ const TypeId = "effect-cf/ServiceBinding" as const;
10
+
11
+ /**
12
+ * Minimum shape for a Cloudflare service binding.
13
+ */
14
+ export interface ServiceFetcher {
15
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
16
+ }
17
+
18
+ type RpcClient<Api> = {
19
+ readonly [Key in keyof Api as Key extends string
20
+ ? Api[Key] extends (...args: Array<any>) => unknown
21
+ ? Key
22
+ : never
23
+ : never]: Api[Key];
24
+ };
25
+
26
+ type ReservedMethodName = WorkerDefinition.ReservedMethodName | "fetch";
27
+
28
+ /**
29
+ * Native Cloudflare service object including optional RPC methods.
30
+ */
31
+ export type ServiceBindingClient<Api extends object> = ServiceFetcher &
32
+ RpcClient<CloudflareRpc.Provider<Api, ReservedMethodName>>;
33
+
34
+ type ApiFromDefinition<Definition> = Definition extends WorkerDefinition.Definition.Any
35
+ ? WorkerDefinition.ServerApi<Definition>
36
+ : never;
37
+
38
+ type ApiOrDefinition<Api extends object, Definition> = [Api] extends [never]
39
+ ? ApiFromDefinition<Definition>
40
+ : Api;
41
+
42
+ /**
43
+ * Binding metadata used to create an Effect service from a Worker binding.
44
+ */
45
+ export interface ServiceBindingDefinition<
46
+ Definition extends WorkerDefinition.Definition.Any | undefined = undefined,
47
+ > {
48
+ /** Binding name as configured in `wrangler.jsonc`. */
49
+ readonly binding: string;
50
+ /** Optional RPC schema used for argument/result encoding. */
51
+ readonly definition?: Definition;
52
+ }
53
+
54
+ /**
55
+ * Failure raised when calling `fetch` on a service binding.
56
+ */
57
+ export class ServiceBindingFetchError extends Data.TaggedError("ServiceBindingFetchError")<{
58
+ readonly binding: string;
59
+ readonly cause: unknown;
60
+ }> {}
61
+
62
+ /**
63
+ * Failure raised when invoking an RPC method on a service binding.
64
+ */
65
+ export class ServiceBindingRpcError extends Data.TaggedError("ServiceBindingRpcError")<{
66
+ readonly binding: string;
67
+ readonly method: string;
68
+ readonly cause: unknown;
69
+ }> {}
70
+
71
+ type ServiceMethodKey<Api> = RpcInvocation.AsyncMethodKey<Api>;
72
+ type ServiceMethodArgs<Api, Method extends keyof Api> = RpcInvocation.AsyncMethodArgs<Api, Method>;
73
+ type ServiceMethodSuccess<Api, Method extends keyof Api> = RpcInvocation.AsyncMethodSuccess<
74
+ Api,
75
+ Method
76
+ >;
77
+
78
+ type ServiceCall<Self, Api> = <Method extends ServiceMethodKey<Api>>(
79
+ method: Method,
80
+ ...args: ServiceMethodArgs<Api, Method>
81
+ ) => Effect.Effect<ServiceMethodSuccess<Api, Method>, ServiceBindingRpcError, Self>;
82
+
83
+ type DefinitionDirectMethods<Self, Definition extends WorkerDefinition.Definition.Any> = {
84
+ readonly [Method in RpcDefinition.Definition.MethodNames<Definition>]: (
85
+ ...args: RpcDefinition.Method.Args<Definition["methods"][Method]>
86
+ ) => Effect.Effect<
87
+ RpcDefinition.Method.Success<Definition["methods"][Method]>,
88
+ ServiceBindingRpcError,
89
+ Self
90
+ >;
91
+ };
92
+
93
+ type DirectMethods<Self, Definition> = Definition extends WorkerDefinition.Definition.Any
94
+ ? DefinitionDirectMethods<Self, Definition>
95
+ : {};
96
+
97
+ /**
98
+ * Creates a typed Effect service for a Worker service binding.
99
+ *
100
+ * Returned value includes:
101
+ * - a Context tag for dependency injection
102
+ * - `fetch(...)` for raw HTTP forwarding
103
+ * - `call(...)` for generic RPC invocation
104
+ * - direct RPC methods when `definition` is provided
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * const Counter = WorkerDefinition.make("Counter", {
109
+ * increment: WorkerDefinition.method({
110
+ * args: [Schema.Number],
111
+ * success: Schema.Number,
112
+ * }),
113
+ * });
114
+ *
115
+ * const CounterService = Counter.binding("COUNTER", { binding: "COUNTER" });
116
+ *
117
+ * const program = Effect.gen(function* () {
118
+ * const next = yield* CounterService.increment(1);
119
+ * return next;
120
+ * });
121
+ * ```
122
+ */
123
+ export const Service =
124
+ <Self, Api extends object = never>() =>
125
+ <
126
+ Id extends string,
127
+ const Definition extends WorkerDefinition.Definition.Any | undefined = undefined,
128
+ >(
129
+ id: Id,
130
+ definition: ServiceBindingDefinition<Definition>,
131
+ ) => {
132
+ type ServiceApi = ApiOrDefinition<Api, Definition>;
133
+
134
+ const tag = Binding.Service<Self>()(
135
+ id,
136
+ definition.binding,
137
+ (value): value is ServiceBindingClient<ServiceApi> =>
138
+ typeof value === "object" && value !== null && "fetch" in value,
139
+ );
140
+
141
+ const fetch = (input: RequestInfo | URL, init?: RequestInit) =>
142
+ Effect.gen(function* () {
143
+ const service = yield* tag;
144
+
145
+ return yield* Effect.tryPromise({
146
+ try: () => service.fetch(input, init),
147
+ catch: (cause) => new ServiceBindingFetchError({ binding: definition.binding, cause }),
148
+ });
149
+ });
150
+
151
+ const rpc = <Method extends ServiceMethodKey<ServiceApi>>(
152
+ method: Method,
153
+ ...args: ServiceMethodArgs<ServiceApi, Method>
154
+ ) =>
155
+ Effect.gen(function* () {
156
+ const service = yield* tag;
157
+ const methodName = String(method);
158
+ const encodedArgs =
159
+ definition.definition === undefined
160
+ ? args
161
+ : yield* RpcDefinition.encodeArgs(
162
+ definition.definition,
163
+ methodName as RpcDefinition.Definition.MethodNames<NonNullable<Definition>>,
164
+ args as never,
165
+ ).pipe(
166
+ Effect.mapError(
167
+ (cause) =>
168
+ new ServiceBindingRpcError({
169
+ binding: definition.binding,
170
+ method: methodName,
171
+ cause,
172
+ }),
173
+ ),
174
+ );
175
+
176
+ return yield* RpcInvocation.invokeRpcMethod(
177
+ service,
178
+ method,
179
+ encodedArgs as ServiceMethodArgs<ServiceApi, Method>,
180
+ (cause) =>
181
+ new ServiceBindingRpcError({
182
+ binding: definition.binding,
183
+ method: methodName,
184
+ cause,
185
+ }),
186
+ );
187
+ });
188
+
189
+ const call = <Method extends ServiceMethodKey<ServiceApi>>(
190
+ method: Method,
191
+ ...args: ServiceMethodArgs<ServiceApi, Method>
192
+ ) =>
193
+ Effect.gen(function* () {
194
+ const methodName = String(method);
195
+ const value = yield* CloudflareRpc.resolve(yield* rpc(method, ...args)).pipe(
196
+ Effect.mapError(
197
+ (cause) =>
198
+ new ServiceBindingRpcError({
199
+ binding: definition.binding,
200
+ method: methodName,
201
+ cause,
202
+ }),
203
+ ),
204
+ );
205
+
206
+ if (definition.definition === undefined) {
207
+ return value as ServiceMethodSuccess<ServiceApi, Method>;
208
+ }
209
+
210
+ const decoded = yield* RpcDefinition.decodeSuccess(
211
+ definition.definition,
212
+ methodName as RpcDefinition.Definition.MethodNames<NonNullable<Definition>>,
213
+ value,
214
+ ).pipe(
215
+ Effect.mapError(
216
+ (cause) =>
217
+ new ServiceBindingRpcError({
218
+ binding: definition.binding,
219
+ method: methodName,
220
+ cause,
221
+ }),
222
+ ),
223
+ );
224
+
225
+ return decoded as ServiceMethodSuccess<ServiceApi, Method>;
226
+ });
227
+
228
+ const scopedCall = <Method extends ServiceMethodKey<ServiceApi>>(
229
+ method: Method,
230
+ ...args: ServiceMethodArgs<ServiceApi, Method>
231
+ ) =>
232
+ Effect.gen(function* () {
233
+ const result = yield* rpc(method, ...args);
234
+ return yield* CloudflareRpc.scoped(result);
235
+ });
236
+
237
+ const directMethods = makeDirectMethods<Self, ServiceApi, Definition>(
238
+ definition.definition,
239
+ call,
240
+ );
241
+
242
+ return Object.assign(tag, directMethods, {
243
+ [TypeId]: TypeId,
244
+ definition,
245
+ fetch,
246
+ rpc,
247
+ call,
248
+ scopedCall,
249
+ }) as typeof tag &
250
+ DirectMethods<Self, Definition> & {
251
+ readonly [TypeId]: typeof TypeId;
252
+ readonly definition: ServiceBindingDefinition<Definition>;
253
+ readonly fetch: typeof fetch;
254
+ readonly rpc: typeof rpc;
255
+ readonly call: typeof call;
256
+ readonly scopedCall: typeof scopedCall;
257
+ };
258
+ };
259
+
260
+ const makeDirectMethods = <
261
+ Self,
262
+ Api,
263
+ Definition extends WorkerDefinition.Definition.Any | undefined,
264
+ >(
265
+ rpcDefinition: Definition | undefined,
266
+ call: ServiceCall<Self, Api>,
267
+ ): DirectMethods<Self, Definition> => {
268
+ const methods = {} as Record<string, unknown>;
269
+
270
+ if (rpcDefinition !== undefined) {
271
+ for (const methodName of Object.keys(rpcDefinition.methods)) {
272
+ methods[methodName] = (...args: Array<unknown>) =>
273
+ (call as (method: string, ...args: Array<unknown>) => unknown)(methodName, ...args);
274
+ }
275
+ }
276
+
277
+ return methods as DirectMethods<Self, Definition>;
278
+ };
package/src/Worker.ts ADDED
@@ -0,0 +1,264 @@
1
+ import { WorkerEntrypoint as CloudflareWorkerEntrypoint } from "cloudflare:workers";
2
+ import { Cause, Context, Effect, Exit, Layer, ManagedRuntime, type Scope } from "effect";
3
+ import { HttpServerRequest } from "effect/unstable/http";
4
+
5
+ import { WorkerEnvironment, type WorkerEnv } from "./Environment";
6
+ import type * as RpcDefinition from "./RpcDefinition";
7
+ import * as WorkerDefinition from "./WorkerDefinition";
8
+ import * as Entrypoint from "./internal/Entrypoint";
9
+
10
+ export class ExecutionContext extends Context.Service<
11
+ ExecutionContext,
12
+ globalThis.ExecutionContext
13
+ >()("effect-cf/ExecutionContext") {}
14
+
15
+ export interface WorkerContextWaitUntilOptions<E, R> {
16
+ readonly onFailure?: (cause: Cause.Cause<E>) => Effect.Effect<void, never, R>;
17
+ }
18
+
19
+ export interface WorkerContextService {
20
+ readonly raw: globalThis.ExecutionContext;
21
+ waitUntil<A, E, R, R2 = never>(
22
+ effect: Effect.Effect<A, E, R>,
23
+ options?: WorkerContextWaitUntilOptions<E, R2>,
24
+ ): Effect.Effect<void, never, R | R2>;
25
+ readonly passThroughOnException: Effect.Effect<void>;
26
+ }
27
+
28
+ export class WorkerContext extends Context.Service<WorkerContext, WorkerContextService>()(
29
+ "effect-cf/WorkerContext",
30
+ ) {}
31
+
32
+ type RunWaitUntilEffect = <A, E>(
33
+ effect: Effect.Effect<A, E, never>,
34
+ ) => Promise<Exit.Exit<A, unknown>>;
35
+
36
+ const fromExecutionContext = (
37
+ ctx: globalThis.ExecutionContext,
38
+ runPromiseExit: RunWaitUntilEffect,
39
+ ): WorkerContextService => ({
40
+ raw: ctx,
41
+ waitUntil: <A, E, R, R2 = never>(
42
+ effect: Effect.Effect<A, E, R>,
43
+ options?: WorkerContextWaitUntilOptions<E, R2>,
44
+ ) =>
45
+ Effect.context<R | R2>().pipe(
46
+ Effect.flatMap((context) =>
47
+ Effect.sync(() => {
48
+ const observed = Effect.exit(effect).pipe(
49
+ Effect.flatMap((exit) => {
50
+ if (Exit.isSuccess(exit)) {
51
+ return Effect.void;
52
+ }
53
+
54
+ const handleFailure =
55
+ options?.onFailure?.(exit.cause) ??
56
+ Effect.logError("WorkerContext.waitUntil failed", Cause.pretty(exit.cause));
57
+
58
+ return handleFailure.pipe(
59
+ Effect.catchCause((handlerCause) =>
60
+ Effect.logError(
61
+ "WorkerContext.waitUntil failure handler failed",
62
+ Cause.pretty(exit.cause),
63
+ Cause.pretty(handlerCause),
64
+ ),
65
+ ),
66
+ );
67
+ }),
68
+ );
69
+
70
+ ctx.waitUntil(
71
+ runPromiseExit(Effect.scoped(Effect.provideContext(observed, context))).then((exit) => {
72
+ if (Exit.isFailure(exit)) {
73
+ console.error(
74
+ "WorkerContext.waitUntil failure handler failed",
75
+ Cause.pretty(exit.cause),
76
+ );
77
+ }
78
+ }),
79
+ );
80
+ }),
81
+ ),
82
+ ),
83
+ passThroughOnException: Effect.sync(() => ctx.passThroughOnException()),
84
+ });
85
+
86
+ export class NativeRequest extends Context.Service<NativeRequest, Request>()(
87
+ "effect-cf/NativeRequest",
88
+ ) {}
89
+
90
+ export const isWebSocketUpgrade = (request: Request): boolean =>
91
+ request.headers.get("Upgrade")?.toLowerCase() === "websocket";
92
+
93
+ export type ReservedMethodName =
94
+ | RpcDefinition.ReservedMethodName
95
+ | "fetch"
96
+ | "connect"
97
+ | "alarm"
98
+ | "webSocketMessage"
99
+ | "webSocketClose"
100
+ | "webSocketError";
101
+
102
+ const reservedMethodNames = new Set<string>([
103
+ "constructor",
104
+ "dup",
105
+ "fetch",
106
+ "connect",
107
+ "alarm",
108
+ "webSocketMessage",
109
+ "webSocketClose",
110
+ "webSocketError",
111
+ ]);
112
+
113
+ type WorkerBaseContext<ROut> = ExecutionContext | WorkerContext | WorkerEnvironment | ROut;
114
+ type WorkerFetchContext<ROut> =
115
+ | WorkerBaseContext<ROut>
116
+ | NativeRequest
117
+ | HttpServerRequest.HttpServerRequest
118
+ | Scope.Scope;
119
+ type WorkerRpcContext<ROut> = WorkerBaseContext<ROut> | Scope.Scope;
120
+
121
+ type RuntimeContext<ROut> = WorkerBaseContext<ROut>;
122
+
123
+ const RunSymbol = Symbol.for("effect-cf/Worker/run");
124
+
125
+ export type WorkerHandler<ROut, A = unknown> = Effect.Effect<A, unknown, WorkerFetchContext<ROut>>;
126
+
127
+ export type WorkerRpcHandler<ROut, A = unknown> = Effect.Effect<A, unknown, WorkerRpcContext<ROut>>;
128
+
129
+ export type WorkerRpc<ROut> = Record<string, (...args: Array<any>) => WorkerRpcHandler<ROut>>;
130
+
131
+ export type WorkerRpcShape<Rpc extends WorkerRpc<ROut>, ROut> = {
132
+ readonly [Key in keyof Rpc]: Rpc[Key] extends (
133
+ ...args: infer Args
134
+ ) => Effect.Effect<infer A, unknown, WorkerRpcContext<ROut>>
135
+ ? (...args: Args) => Promise<A>
136
+ : never;
137
+ };
138
+
139
+ export type RpcHandlers<ROut, Api> = {
140
+ readonly [Key in keyof Api as Key extends keyof CloudflareWorkerEntrypoint
141
+ ? never
142
+ : Key extends string
143
+ ? Key extends ReservedMethodName
144
+ ? never
145
+ : [Api[Key]] extends [never]
146
+ ? never
147
+ : Api[Key] extends (...args: Array<any>) => Promise<unknown>
148
+ ? Key
149
+ : never
150
+ : never]: Api[Key] extends (...args: infer Args) => Promise<infer A>
151
+ ? (...args: Args) => WorkerRpcHandler<ROut, A>
152
+ : never;
153
+ };
154
+
155
+ export interface WorkerOptions<ROut, Rpc extends WorkerRpc<ROut>> {
156
+ readonly fetch?: Effect.Effect<Response, unknown, WorkerFetchContext<ROut>>;
157
+ readonly rpc?: Rpc;
158
+ }
159
+
160
+ export type WorkerClass<Rpc extends WorkerRpc<ROut>, ROut> = new (
161
+ ctx: globalThis.ExecutionContext,
162
+ env: WorkerEnv,
163
+ ) => CloudflareWorkerEntrypoint<WorkerEnv> & {
164
+ fetch(request: Request): Promise<Response>;
165
+ } & WorkerRpcShape<Rpc, ROut>;
166
+
167
+ export const make = <ROut, LayerError, const Rpc extends WorkerRpc<ROut> = Record<never, never>>(
168
+ layer: Layer.Layer<ROut, LayerError, ExecutionContext | WorkerContext | WorkerEnvironment>,
169
+ options: WorkerOptions<ROut, Rpc>,
170
+ ): WorkerClass<Rpc, ROut> => {
171
+ class EffectWorker extends CloudflareWorkerEntrypoint<WorkerEnv> {
172
+ readonly runtime: ManagedRuntime.ManagedRuntime<RuntimeContext<ROut>, LayerError>;
173
+
174
+ constructor(ctx: globalThis.ExecutionContext, env: WorkerEnv) {
175
+ super(ctx, env);
176
+
177
+ let runWaitUntilEffect: RunWaitUntilEffect = () =>
178
+ Promise.resolve(Exit.die(new Error("WorkerContext runtime is not initialized")));
179
+
180
+ const services = Layer.mergeAll(
181
+ Layer.succeed(ExecutionContext, ctx),
182
+ Layer.succeed(
183
+ WorkerContext,
184
+ fromExecutionContext(ctx, (effect) => runWaitUntilEffect(effect)),
185
+ ),
186
+ Layer.succeed(WorkerEnvironment, env),
187
+ );
188
+
189
+ const runtimeLayer = Entrypoint.provideEntrypointServices(layer, services);
190
+
191
+ this.runtime = ManagedRuntime.make(runtimeLayer);
192
+ runWaitUntilEffect = <A, E>(effect: Effect.Effect<A, E, never>) =>
193
+ this.runtime.runPromiseExit(effect as Effect.Effect<A, E, RuntimeContext<ROut>>);
194
+ }
195
+
196
+ [RunSymbol]<A, E>(effect: Effect.Effect<A, E, RuntimeContext<ROut> | Scope.Scope>): Promise<A> {
197
+ return this.runtime.runPromise(Effect.scoped(effect));
198
+ }
199
+
200
+ fetch(request: Request): Promise<Response> {
201
+ const fetchHandler = options.fetch;
202
+
203
+ if (fetchHandler === undefined) {
204
+ return Promise.resolve(new Response("Not Found", { status: 404 }));
205
+ }
206
+
207
+ return this[RunSymbol](
208
+ fetchHandler.pipe(
209
+ Effect.provideService(NativeRequest, request),
210
+ Effect.provideService(
211
+ HttpServerRequest.HttpServerRequest,
212
+ HttpServerRequest.fromWeb(request),
213
+ ),
214
+ ),
215
+ );
216
+ }
217
+ }
218
+
219
+ Entrypoint.defineEntrypointRpcMethods<EffectWorker>(
220
+ "Worker",
221
+ EffectWorker.prototype,
222
+ options.rpc,
223
+ reservedMethodNames,
224
+ (self, effect) => self[RunSymbol](effect),
225
+ );
226
+
227
+ return Entrypoint.assumeEntrypointClass<WorkerClass<Rpc, ROut>>(EffectWorker);
228
+ };
229
+
230
+ export const Tag = WorkerDefinition.Tag;
231
+
232
+ export const method = WorkerDefinition.method;
233
+
234
+ export const implement = WorkerDefinition.implement;
235
+
236
+ export type Definition<
237
+ Id extends string = string,
238
+ Methods extends RpcDefinition.Methods = RpcDefinition.Methods,
239
+ > = WorkerDefinition.Definition<Id, Methods>;
240
+
241
+ export namespace Definition {
242
+ export type Any = WorkerDefinition.Definition.Any;
243
+ }
244
+
245
+ export type ServerApi<Self extends WorkerDefinition.Definition.Any> =
246
+ WorkerDefinition.ServerApi<Self>;
247
+
248
+ export type Api<Self extends WorkerDefinition.Definition.Any> = WorkerDefinition.Api<Self>;
249
+
250
+ export type Handlers<
251
+ ROut,
252
+ Self extends WorkerDefinition.Definition.Any,
253
+ > = WorkerDefinition.Handlers<ROut, Self>;
254
+
255
+ export type Options<ROut, Self extends WorkerDefinition.Definition.Any> = WorkerDefinition.Options<
256
+ ROut,
257
+ Self
258
+ >;
259
+
260
+ export type HandlerEffect<
261
+ ROut,
262
+ Self extends WorkerDefinition.Definition.Any,
263
+ Key extends keyof Self["methods"],
264
+ > = WorkerDefinition.HandlerEffect<ROut, Self, Key>;