effect-cf 0.1.0 → 0.3.0

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,131 @@
1
+ import { Data, Effect, Schema as S } from "effect";
2
+
3
+ import * as Binding from "./Binding";
4
+ import type * as RpcDefinition from "./RpcDefinition";
5
+
6
+ const TypeId = "effect-cf/QueueBinding" as const;
7
+
8
+ export type QueueSendOptions = globalThis.QueueSendOptions;
9
+ export type QueueSendResponse = globalThis.QueueSendResponse;
10
+ export type QueueSendBatchOptions = globalThis.QueueSendBatchOptions;
11
+ export type QueueSendBatchResponse = globalThis.QueueSendBatchResponse;
12
+ export type QueueMetrics = globalThis.QueueMetrics;
13
+ export type MessageSendRequest<Body> = globalThis.MessageSendRequest<Body>;
14
+
15
+ export interface QueueBindingDefinition<Message extends RpcDefinition.ServiceFreeSchema> {
16
+ /** Binding name as configured in `wrangler.jsonc`. */
17
+ readonly binding: string;
18
+ /** Codec used to encode messages before sending them to Cloudflare Queues. */
19
+ readonly message: Message;
20
+ }
21
+
22
+ declare const QueueServiceTypeId: unique symbol;
23
+
24
+ /** Nominal service marker for Queue services created with {@link make}. */
25
+ export interface QueueService<Id extends string, Message extends RpcDefinition.ServiceFreeSchema> {
26
+ readonly [QueueServiceTypeId]: {
27
+ readonly id: Id;
28
+ readonly message: S.Schema.Type<Message>;
29
+ readonly encodedMessage: S.Codec.Encoded<Message>;
30
+ };
31
+ }
32
+
33
+ export class QueueOperationError extends Data.TaggedError("QueueOperationError")<{
34
+ readonly binding: string;
35
+ readonly operation: string;
36
+ readonly cause: unknown;
37
+ }> {}
38
+
39
+ const queueError = (binding: string, operation: string, cause: unknown) =>
40
+ new QueueOperationError({ binding, operation, cause });
41
+
42
+ const tryQueuePromise = <A>(
43
+ binding: string,
44
+ operation: string,
45
+ evaluate: () => Promise<A>,
46
+ ): Effect.Effect<A, QueueOperationError> =>
47
+ Effect.tryPromise({
48
+ try: evaluate,
49
+ catch: (cause) => queueError(binding, operation, cause),
50
+ });
51
+
52
+ const isQueue = <Body>(value: unknown): value is globalThis.Queue<Body> => {
53
+ if (typeof value !== "object" || value === null) {
54
+ return false;
55
+ }
56
+
57
+ const resource = value as Record<string, unknown>;
58
+
59
+ return (
60
+ typeof resource.send === "function" &&
61
+ typeof resource.sendBatch === "function" &&
62
+ typeof resource.metrics === "function"
63
+ );
64
+ };
65
+
66
+ /** Creates a typed Queue binding service tag plus Effect helpers. */
67
+ export const make = <Id extends string, Message extends RpcDefinition.ServiceFreeSchema>(
68
+ id: Id,
69
+ definition: QueueBindingDefinition<Message>,
70
+ ) => Service<QueueService<Id, Message>>()<Id, Message>(id, definition);
71
+
72
+ /**
73
+ * Builds an Effect service around a Cloudflare Queue producer binding.
74
+ */
75
+ export const Service =
76
+ <Self>() =>
77
+ <Id extends string, Message extends RpcDefinition.ServiceFreeSchema>(
78
+ id: Id,
79
+ definition: QueueBindingDefinition<Message>,
80
+ ) => {
81
+ type Body = S.Schema.Type<Message>;
82
+ type EncodedBody = S.Codec.Encoded<Message>;
83
+
84
+ const tag = Binding.Service<Self>()(
85
+ id,
86
+ definition.binding,
87
+ (value): value is globalThis.Queue<EncodedBody> => isQueue<EncodedBody>(value),
88
+ );
89
+
90
+ const encodeMessage = S.encodeEffect(definition.message);
91
+
92
+ const send = Effect.fnUntraced(function* (message: Body, options?: QueueSendOptions) {
93
+ const queue = yield* tag;
94
+ const encoded = yield* encodeMessage(message);
95
+
96
+ return yield* tryQueuePromise(definition.binding, "send", () => queue.send(encoded, options));
97
+ });
98
+
99
+ const sendBatch = Effect.fnUntraced(function* (
100
+ messages: Iterable<MessageSendRequest<Body>>,
101
+ options?: QueueSendBatchOptions,
102
+ ) {
103
+ const queue = yield* tag;
104
+ const encodedMessages: Array<MessageSendRequest<EncodedBody>> = [];
105
+
106
+ for (const message of messages) {
107
+ encodedMessages.push({
108
+ ...message,
109
+ body: yield* encodeMessage(message.body),
110
+ });
111
+ }
112
+
113
+ return yield* tryQueuePromise(definition.binding, "sendBatch", () =>
114
+ queue.sendBatch(encodedMessages, options),
115
+ );
116
+ });
117
+
118
+ const metrics = Effect.fnUntraced(function* () {
119
+ const queue = yield* tag;
120
+
121
+ return yield* tryQueuePromise(definition.binding, "metrics", () => queue.metrics());
122
+ });
123
+
124
+ return Object.assign(tag, {
125
+ [TypeId]: TypeId,
126
+ binding: definition.binding,
127
+ send,
128
+ sendBatch,
129
+ metrics,
130
+ });
131
+ };
@@ -0,0 +1,129 @@
1
+ import { Effect, Schema as S, type Layer, type Scope } from "effect";
2
+
3
+ import type { WorkerEnvironment } from "./Environment";
4
+ import * as QueueEntrypoint from "./Queue";
5
+ import * as QueueBinding from "./QueueBinding";
6
+ import type { ExecutionContext, WorkerContext } from "./Worker";
7
+ import * as WorkerEntrypoint from "./Worker";
8
+ import type * as RpcDefinition from "./RpcDefinition";
9
+
10
+ export interface Definition<
11
+ Id extends string = string,
12
+ Message extends RpcDefinition.ServiceFreeSchema = RpcDefinition.ServiceFreeSchema,
13
+ > {
14
+ readonly id: Id;
15
+ readonly message: Message;
16
+ }
17
+
18
+ export namespace Definition {
19
+ export type Any = Definition<string, RpcDefinition.ServiceFreeSchema>;
20
+ }
21
+
22
+ declare const BindingServiceTypeId: unique symbol;
23
+
24
+ export interface BindingService<Id extends string, Self extends Definition.Any> {
25
+ readonly [BindingServiceTypeId]: {
26
+ readonly id: Id;
27
+ readonly definition: Self;
28
+ };
29
+ }
30
+
31
+ export type Handler<ROut, Self extends Definition.Any> = (
32
+ batch: QueueEntrypoint.QueueBatch<S.Schema.Type<Self["message"]>>,
33
+ ) => Effect.Effect<
34
+ void,
35
+ unknown,
36
+ ExecutionContext | WorkerEntrypoint.WorkerContext | WorkerEnvironment | Scope.Scope | ROut
37
+ >;
38
+
39
+ export interface Options<ROut, Self extends Definition.Any> extends Omit<
40
+ WorkerEntrypoint.WorkerOptions<ROut, Record<never, never>>,
41
+ "queue" | "rpc"
42
+ > {
43
+ readonly queue: Handler<ROut, Self>;
44
+ readonly rpc?: never;
45
+ }
46
+
47
+ const makeDefinition = <Id extends string, Message extends RpcDefinition.ServiceFreeSchema>(
48
+ id: Id,
49
+ definition: { readonly message: Message },
50
+ ) => {
51
+ type SelfDefinition = Definition<Id, Message>;
52
+ const queueDefinition: SelfDefinition = {
53
+ id,
54
+ message: definition.message,
55
+ };
56
+
57
+ return Object.assign(queueDefinition, {
58
+ make: <ROut, LayerError>(
59
+ layer: Layer.Layer<ROut, LayerError, ExecutionContext | WorkerContext | WorkerEnvironment>,
60
+ options: Options<ROut, SelfDefinition>,
61
+ ) =>
62
+ WorkerEntrypoint.make(layer, {
63
+ ...options,
64
+ queue: wrapHandler(queueDefinition, options.queue),
65
+ }),
66
+ Binding:
67
+ <Self>() =>
68
+ <BindingId extends string>(
69
+ bindingId: BindingId,
70
+ binding: Omit<QueueBinding.QueueBindingDefinition<Message>, "message">,
71
+ ) =>
72
+ QueueBinding.Service<Self>()<BindingId, Message>(bindingId, {
73
+ ...binding,
74
+ message: definition.message,
75
+ }),
76
+ binding: <BindingId extends string>(
77
+ bindingId: BindingId,
78
+ binding: Omit<QueueBinding.QueueBindingDefinition<Message>, "message">,
79
+ ) =>
80
+ QueueBinding.Service<BindingService<BindingId, SelfDefinition>>()<BindingId, Message>(
81
+ bindingId,
82
+ {
83
+ ...binding,
84
+ message: definition.message,
85
+ },
86
+ ),
87
+ });
88
+ };
89
+
90
+ export const make = makeDefinition;
91
+
92
+ export const Tag =
93
+ <_Self>() =>
94
+ <Id extends string, Message extends RpcDefinition.ServiceFreeSchema>(
95
+ id: Id,
96
+ definition: { readonly message: Message },
97
+ ) => {
98
+ const queueDefinition = makeDefinition(id, definition);
99
+
100
+ abstract class QueueDefinitionClass {
101
+ static readonly id = queueDefinition.id;
102
+ static readonly message = queueDefinition.message;
103
+ static readonly make = queueDefinition.make;
104
+ static readonly Binding = queueDefinition.Binding;
105
+ static readonly binding = queueDefinition.binding;
106
+ }
107
+
108
+ return QueueDefinitionClass as (abstract new () => object) & typeof queueDefinition;
109
+ };
110
+
111
+ export const Queue = Tag;
112
+
113
+ const wrapHandler = <ROut, const Self extends Definition.Any>(
114
+ definition: Self,
115
+ handler: Handler<ROut, Self>,
116
+ ): QueueEntrypoint.QueueHandler<ROut> => {
117
+ const decodeBody = S.decodeUnknownEffect(definition.message);
118
+
119
+ return (batch) =>
120
+ Effect.gen(function* () {
121
+ const decoded = yield* QueueEntrypoint.decodeBatch(batch.raw, decodeBody);
122
+ yield* handler(decoded);
123
+ });
124
+ };
125
+
126
+ export const implement = <ROut, const Self extends Definition.Any>(
127
+ _definition: Self,
128
+ handler: Handler<ROut, Self>,
129
+ ): Handler<ROut, Self> => handler;
package/src/Worker.ts CHANGED
@@ -1,20 +1,13 @@
1
1
  import { WorkerEntrypoint as CloudflareWorkerEntrypoint } from "cloudflare:workers";
2
- import {
3
- Cause,
4
- ConfigProvider,
5
- Context,
6
- Effect,
7
- Exit,
8
- Layer,
9
- ManagedRuntime,
10
- type Scope,
11
- } from "effect";
2
+ import { Cause, ConfigProvider, Context, Effect, Layer, ManagedRuntime, type Scope } from "effect";
12
3
  import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
13
4
 
14
5
  import { WorkerConfig, WorkerEnvironment, type WorkerEnv } from "./Environment";
6
+ import { fromMessage, fromMessageBatch, type QueueHandler } from "./Queue";
15
7
  import type * as RpcDefinition from "./RpcDefinition";
16
8
  import * as WorkerDefinition from "./WorkerDefinition";
17
9
  import * as Entrypoint from "./internal/Entrypoint";
10
+ import { fromExecutionContext, type RunWaitUntilEffect } from "./internal/WorkerContext";
18
11
 
19
12
  export class ExecutionContext extends Context.Service<
20
13
  ExecutionContext,
@@ -22,6 +15,7 @@ export class ExecutionContext extends Context.Service<
22
15
  >()("effect-cf/ExecutionContext") {}
23
16
 
24
17
  export interface WorkerContextWaitUntilOptions<E, R> {
18
+ readonly mode?: "observe" | "propagate";
25
19
  readonly onFailure?: (cause: Cause.Cause<E>) => Effect.Effect<void, never, R>;
26
20
  }
27
21
 
@@ -31,6 +25,10 @@ export interface WorkerContextService {
31
25
  effect: Effect.Effect<A, E, R>,
32
26
  options?: WorkerContextWaitUntilOptions<E, R2>,
33
27
  ): Effect.Effect<void, never, R | R2>;
28
+ waitUntilPropagating<A, E, R, R2 = never>(
29
+ effect: Effect.Effect<A, E, R>,
30
+ options?: Omit<WorkerContextWaitUntilOptions<E, R2>, "mode">,
31
+ ): Effect.Effect<void, never, R | R2>;
34
32
  readonly passThroughOnException: Effect.Effect<void>;
35
33
  }
36
34
 
@@ -38,60 +36,6 @@ export class WorkerContext extends Context.Service<WorkerContext, WorkerContextS
38
36
  "effect-cf/WorkerContext",
39
37
  ) {}
40
38
 
41
- type RunWaitUntilEffect = <A, E>(
42
- effect: Effect.Effect<A, E, never>,
43
- ) => Promise<Exit.Exit<A, unknown>>;
44
-
45
- const fromExecutionContext = (
46
- ctx: globalThis.ExecutionContext,
47
- runPromiseExit: RunWaitUntilEffect,
48
- ): WorkerContextService => ({
49
- raw: ctx,
50
- waitUntil: <A, E, R, R2 = never>(
51
- effect: Effect.Effect<A, E, R>,
52
- options?: WorkerContextWaitUntilOptions<E, R2>,
53
- ) =>
54
- Effect.context<R | R2>().pipe(
55
- Effect.flatMap((context) =>
56
- Effect.sync(() => {
57
- const observed = Effect.exit(effect).pipe(
58
- Effect.flatMap((exit) => {
59
- if (Exit.isSuccess(exit)) {
60
- return Effect.void;
61
- }
62
-
63
- const handleFailure =
64
- options?.onFailure?.(exit.cause) ??
65
- Effect.logError("WorkerContext.waitUntil failed", Cause.pretty(exit.cause));
66
-
67
- return handleFailure.pipe(
68
- Effect.catchCause((handlerCause) =>
69
- Effect.logError(
70
- "WorkerContext.waitUntil failure handler failed",
71
- Cause.pretty(exit.cause),
72
- Cause.pretty(handlerCause),
73
- ),
74
- ),
75
- );
76
- }),
77
- );
78
-
79
- ctx.waitUntil(
80
- runPromiseExit(Effect.scoped(Effect.provideContext(observed, context))).then((exit) => {
81
- if (Exit.isFailure(exit)) {
82
- console.error(
83
- "WorkerContext.waitUntil failure handler failed",
84
- Cause.pretty(exit.cause),
85
- );
86
- }
87
- }),
88
- );
89
- }),
90
- ),
91
- ),
92
- passThroughOnException: Effect.sync(() => ctx.passThroughOnException()),
93
- });
94
-
95
39
  export class NativeRequest extends Context.Service<NativeRequest, Request>()(
96
40
  "effect-cf/NativeRequest",
97
41
  ) {}
@@ -103,6 +47,12 @@ export type ReservedMethodName =
103
47
  | RpcDefinition.ReservedMethodName
104
48
  | "fetch"
105
49
  | "connect"
50
+ | "queue"
51
+ | "scheduled"
52
+ | "tail"
53
+ | "tailStream"
54
+ | "test"
55
+ | "trace"
106
56
  | "alarm"
107
57
  | "webSocketMessage"
108
58
  | "webSocketClose"
@@ -113,6 +63,12 @@ const reservedMethodNames = new Set<string>([
113
63
  "dup",
114
64
  "fetch",
115
65
  "connect",
66
+ "queue",
67
+ "scheduled",
68
+ "tail",
69
+ "tailStream",
70
+ "test",
71
+ "trace",
116
72
  "alarm",
117
73
  "webSocketMessage",
118
74
  "webSocketClose",
@@ -169,6 +125,7 @@ export type RpcHandlers<ROut, Api> = {
169
125
 
170
126
  export interface WorkerOptions<ROut, Rpc extends WorkerRpc<ROut>> {
171
127
  readonly fetch?: Effect.Effect<WorkerFetchSuccess, unknown, WorkerFetchContext<ROut>>;
128
+ readonly queue?: QueueHandler<ROut>;
172
129
  readonly rpc?: Rpc;
173
130
  }
174
131
 
@@ -181,6 +138,7 @@ export type WorkerClass<Rpc extends WorkerRpc<ROut>, ROut> = new (
181
138
  env: WorkerEnv,
182
139
  ) => CloudflareWorkerEntrypoint<WorkerEnv> & {
183
140
  fetch(request: Request): Promise<Response>;
141
+ queue(batch: globalThis.MessageBatch): Promise<void>;
184
142
  } & WorkerRpcShape<Rpc, ROut>;
185
143
 
186
144
  export interface FetchHandler<Env extends WorkerEnv = WorkerEnv> {
@@ -211,10 +169,29 @@ const renderFetchSuccess = <E, R>(
211
169
  ),
212
170
  );
213
171
 
214
- export const make = <ROut, LayerError, const Rpc extends WorkerRpc<ROut> = Record<never, never>>(
172
+ const isWorkerOptions = <ROut, Rpc extends WorkerRpc<ROut>>(
173
+ options: WorkerOptions<ROut, Rpc> | WorkerHandler<ROut>,
174
+ ): options is WorkerOptions<ROut, Rpc> =>
175
+ typeof options === "object" &&
176
+ options !== null &&
177
+ ("fetch" in options || "queue" in options || "rpc" in options);
178
+
179
+ export function make<ROut, LayerError>(
180
+ layer: Layer.Layer<ROut, LayerError, ExecutionContext | WorkerContext | WorkerEnvironment>,
181
+ fetch: WorkerHandler<ROut>,
182
+ ): WorkerClass<Record<never, never>, ROut>;
183
+ export function make<ROut, LayerError, const Rpc extends WorkerRpc<ROut> = Record<never, never>>(
215
184
  layer: Layer.Layer<ROut, LayerError, ExecutionContext | WorkerContext | WorkerEnvironment>,
216
185
  options: WorkerOptions<ROut, Rpc>,
217
- ): WorkerClass<Rpc, ROut> => {
186
+ ): WorkerClass<Rpc, ROut>;
187
+ export function make<ROut, LayerError, const Rpc extends WorkerRpc<ROut> = Record<never, never>>(
188
+ layer: Layer.Layer<ROut, LayerError, ExecutionContext | WorkerContext | WorkerEnvironment>,
189
+ optionsOrFetch: WorkerOptions<ROut, Rpc> | WorkerHandler<ROut>,
190
+ ): WorkerClass<Rpc, ROut> {
191
+ const options = isWorkerOptions(optionsOrFetch)
192
+ ? optionsOrFetch
193
+ : ({ fetch: optionsOrFetch } as WorkerOptions<ROut, Rpc>);
194
+
218
195
  class EffectWorker extends CloudflareWorkerEntrypoint<WorkerEnv> {
219
196
  readonly runtime: ManagedRuntime.ManagedRuntime<RuntimeContext<ROut>, LayerError>;
220
197
 
@@ -222,7 +199,7 @@ export const make = <ROut, LayerError, const Rpc extends WorkerRpc<ROut> = Recor
222
199
  super(ctx, env);
223
200
 
224
201
  let runWaitUntilEffect: RunWaitUntilEffect = () =>
225
- Promise.resolve(Exit.die(new Error("WorkerContext runtime is not initialized")));
202
+ Promise.reject(new Error("WorkerContext runtime is not initialized"));
226
203
 
227
204
  const services = Layer.mergeAll(
228
205
  Layer.succeed(ExecutionContext, ctx),
@@ -269,6 +246,18 @@ export const make = <ROut, LayerError, const Rpc extends WorkerRpc<ROut> = Recor
269
246
  >,
270
247
  );
271
248
  }
249
+
250
+ queue(batch: globalThis.MessageBatch): Promise<void> {
251
+ const queueHandler = options.queue;
252
+
253
+ if (queueHandler === undefined) {
254
+ return Promise.resolve();
255
+ }
256
+
257
+ const messages = batch.messages.map((message) => fromMessage(message, message.body));
258
+
259
+ return this[RunSymbol](queueHandler(fromMessageBatch(batch, messages)));
260
+ }
272
261
  }
273
262
 
274
263
  Entrypoint.defineEntrypointRpcMethods<EffectWorker>(
@@ -280,7 +269,7 @@ export const make = <ROut, LayerError, const Rpc extends WorkerRpc<ROut> = Recor
280
269
  );
281
270
 
282
271
  return Entrypoint.assumeEntrypointClass<WorkerClass<Rpc, ROut>>(EffectWorker);
283
- };
272
+ }
284
273
 
285
274
  export const makeFetchHandler = <ROut, LayerError, Env extends WorkerEnv = WorkerEnv>(
286
275
  layer: Layer.Layer<ROut, LayerError, ExecutionContext | WorkerContext | WorkerEnvironment>,
@@ -28,6 +28,12 @@ const reservedMethodNames = new Set<string>([
28
28
  "dup",
29
29
  "fetch",
30
30
  "connect",
31
+ "queue",
32
+ "scheduled",
33
+ "tail",
34
+ "tailStream",
35
+ "test",
36
+ "trace",
31
37
  "alarm",
32
38
  "webSocketMessage",
33
39
  "webSocketClose",