effect-cf 0.1.0 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-cf",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Effect-native primitives for Cloudflare Workers and bindings.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,6 +9,7 @@
9
9
  "directory": "packages/effect-cf"
10
10
  },
11
11
  "files": [
12
+ "CHANGELOG.md",
12
13
  "dist",
13
14
  "src"
14
15
  ],
@@ -38,7 +39,7 @@
38
39
  "@effect/vitest": "catalog:",
39
40
  "effect": "catalog:",
40
41
  "typescript": "^6.0.2",
41
- "vite-plus": "0.1.19",
42
+ "vite-plus": "0.1.20",
42
43
  "vitest": "catalog:"
43
44
  },
44
45
  "peerDependencies": {
@@ -4,6 +4,7 @@ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
4
4
  import * as RpcServer from "effect/unstable/rpc/RpcServer";
5
5
 
6
6
  import { DurableObjectState } from "./DurableObjectState";
7
+ import type { DurableWebSocket } from "./DurableObjectWebSocket";
7
8
 
8
9
  const defaultAttachmentKey = "effectCloudflareRpcClientId";
9
10
  const defaultTag = "effect-cf-rpc";
@@ -25,15 +26,18 @@ export type NativeWebSocketMessage = string | ArrayBuffer;
25
26
  * Service API used to wire websocket lifecycle events to Effect RPC server protocol.
26
27
  */
27
28
  export interface DurableObjectRpcWebSocketService {
28
- readonly accept: (socket: WebSocket) => Effect.Effect<void>;
29
- readonly message: (socket: WebSocket, message: NativeWebSocketMessage) => Effect.Effect<void>;
30
- readonly close: (socket: WebSocket) => Effect.Effect<void>;
31
- readonly error: (socket: WebSocket, error: unknown) => Effect.Effect<void>;
29
+ readonly accept: (socket: DurableWebSocket) => Effect.Effect<void>;
30
+ readonly message: (
31
+ socket: DurableWebSocket,
32
+ message: NativeWebSocketMessage,
33
+ ) => Effect.Effect<void>;
34
+ readonly close: (socket: DurableWebSocket) => Effect.Effect<void>;
35
+ readonly error: (socket: DurableWebSocket, error: unknown) => Effect.Effect<void>;
32
36
  }
33
37
 
34
38
  interface RpcConnection {
35
39
  readonly id: number;
36
- readonly socket: WebSocket;
40
+ readonly socket: DurableWebSocket;
37
41
  readonly parser: RpcSerialization.Parser;
38
42
  }
39
43
 
@@ -64,20 +68,20 @@ export const layer = (options: LayerOptions = {}) =>
64
68
  | ((clientId: number, data: RpcMessage.FromClientEncoded) => Effect.Effect<void>)
65
69
  | undefined;
66
70
 
67
- const reserveClientId = (socket: WebSocket) => {
68
- const attachment = readAttachment(socket, attachmentKey);
71
+ const reserveClientId = (socket: DurableWebSocket) => {
72
+ const attachment = readAttachment(socket.raw, attachmentKey);
69
73
  if (attachment !== undefined) {
70
74
  nextClientId = Math.max(nextClientId, attachment + 1);
71
75
  return attachment;
72
76
  }
73
77
 
74
78
  const id = nextClientId++;
75
- writeAttachment(socket, attachmentKey, id);
79
+ writeAttachment(socket.raw, attachmentKey, id);
76
80
  return id;
77
81
  };
78
82
 
79
- const register = (socket: WebSocket) => {
80
- const existing = connectionsBySocket.get(socket);
83
+ const register = (socket: DurableWebSocket) => {
84
+ const existing = connectionsBySocket.get(socket.raw);
81
85
  if (existing !== undefined) {
82
86
  return existing;
83
87
  }
@@ -88,20 +92,20 @@ export const layer = (options: LayerOptions = {}) =>
88
92
  parser: serialization.makeUnsafe(),
89
93
  } satisfies RpcConnection;
90
94
 
91
- connectionsBySocket.set(socket, connection);
95
+ connectionsBySocket.set(socket.raw, connection);
92
96
  connectionsById.set(connection.id, connection);
93
97
  clientIds.add(connection.id);
94
98
  return connection;
95
99
  };
96
100
 
97
- const unregister = (socket: WebSocket) =>
101
+ const unregister = (socket: DurableWebSocket) =>
98
102
  Effect.sync(() => {
99
- const connection = connectionsBySocket.get(socket);
103
+ const connection = connectionsBySocket.get(socket.raw);
100
104
  if (connection === undefined) {
101
105
  return;
102
106
  }
103
107
 
104
- connectionsBySocket.delete(socket);
108
+ connectionsBySocket.delete(socket.raw);
105
109
  connectionsById.delete(connection.id);
106
110
  clientIds.delete(connection.id);
107
111
  Queue.offerUnsafe(disconnects, connection.id);
@@ -116,12 +120,12 @@ export const layer = (options: LayerOptions = {}) =>
116
120
  try {
117
121
  const encoded = connection.parser.encode(response);
118
122
  if (encoded !== undefined) {
119
- connection.socket.send(encoded);
123
+ connection.socket.raw.send(encoded);
120
124
  }
121
125
  } catch (cause) {
122
126
  const encoded = connection.parser.encode(RpcMessage.ResponseDefectEncoded(cause));
123
127
  if (encoded !== undefined) {
124
- connection.socket.send(encoded);
128
+ connection.socket.raw.send(encoded);
125
129
  }
126
130
  }
127
131
  });
@@ -138,7 +142,7 @@ export const layer = (options: LayerOptions = {}) =>
138
142
  end: (clientId) =>
139
143
  Effect.sync(() => {
140
144
  const connection = connectionsById.get(clientId);
141
- connection?.socket.close();
145
+ connection?.socket.raw.close();
142
146
  }),
143
147
  clientIds: Effect.sync(() => clientIds),
144
148
  initialMessage: Effect.succeed(Option.none()),
@@ -1,6 +1,7 @@
1
1
  import { Cause, Context, Effect, Exit } from "effect";
2
2
 
3
3
  import { fromDurableObjectStorage, type DurableObjectStorage } from "./DurableObjectStorage";
4
+ import { fromWebSocket, type DurableWebSocket } from "./DurableObjectWebSocket";
4
5
 
5
6
  /**
6
7
  * Effect-friendly wrapper around Cloudflare `DurableObjectState`.
@@ -35,21 +36,21 @@ export interface DurableObjectStateService {
35
36
  */
36
37
  blockConcurrencyWhileOrReset<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>;
37
38
  /** Accepts a websocket connection for hibernation-capable Durable Objects. */
38
- acceptWebSocket(ws: WebSocket, tags?: Array<string>): Effect.Effect<void>;
39
+ acceptWebSocket(ws: DurableWebSocket, tags?: Array<string>): Effect.Effect<void>;
39
40
  /** Lists active sockets, optionally filtered by tag. */
40
- getWebSockets(tag?: string): Effect.Effect<Array<WebSocket>>;
41
+ getWebSockets(tag?: string): Effect.Effect<Array<DurableWebSocket>>;
41
42
  /** Configures automatic request/response handling for sockets. */
42
43
  setWebSocketAutoResponse(pair?: WebSocketRequestResponsePair): Effect.Effect<void>;
43
44
  /** Gets the configured websocket auto-response pair. */
44
45
  getWebSocketAutoResponse: Effect.Effect<WebSocketRequestResponsePair | null>;
45
46
  /** Timestamp of the last automatic websocket response for a socket. */
46
- getWebSocketAutoResponseTimestamp(ws: WebSocket): Effect.Effect<Date | null>;
47
+ getWebSocketAutoResponseTimestamp(ws: DurableWebSocket): Effect.Effect<Date | null>;
47
48
  /** Sets the timeout for hibernatable websocket events. */
48
49
  setHibernatableWebSocketEventTimeout(timeoutMs?: number): Effect.Effect<void>;
49
50
  /** Gets the timeout for hibernatable websocket events. */
50
51
  getHibernatableWebSocketEventTimeout: Effect.Effect<number | null>;
51
52
  /** Reads tags attached to a websocket. */
52
- getTags(ws: WebSocket): Effect.Effect<Array<string>>;
53
+ getTags(ws: DurableWebSocket): Effect.Effect<Array<string>>;
53
54
  /**
54
55
  * Forcibly resets the Durable Object. Cloudflare logs an uncaught Error using
55
56
  * the optional reason, and the method is not available in `wrangler dev` local
@@ -97,20 +98,21 @@ export const fromDurableObjectState = (
97
98
  ),
98
99
  ),
99
100
  ),
100
- acceptWebSocket: (ws: WebSocket, tags?: Array<string>) =>
101
- Effect.sync(() => state.acceptWebSocket(ws, tags)),
102
- getWebSockets: (tag?: string) => Effect.sync(() => state.getWebSockets(tag)),
101
+ acceptWebSocket: (ws: DurableWebSocket, tags?: Array<string>) =>
102
+ Effect.sync(() => state.acceptWebSocket(ws.raw, tags)),
103
+ getWebSockets: (tag?: string) =>
104
+ Effect.sync(() => state.getWebSockets(tag).map((socket) => fromWebSocket(socket))),
103
105
  setWebSocketAutoResponse: (pair?: WebSocketRequestResponsePair) =>
104
106
  Effect.sync(() => state.setWebSocketAutoResponse(pair)),
105
107
  getWebSocketAutoResponse: Effect.sync(() => state.getWebSocketAutoResponse()),
106
- getWebSocketAutoResponseTimestamp: (ws: WebSocket) =>
107
- Effect.sync(() => state.getWebSocketAutoResponseTimestamp(ws)),
108
+ getWebSocketAutoResponseTimestamp: (ws: DurableWebSocket) =>
109
+ Effect.sync(() => state.getWebSocketAutoResponseTimestamp(ws.raw)),
108
110
  setHibernatableWebSocketEventTimeout: (timeoutMs?: number) =>
109
111
  Effect.sync(() => state.setHibernatableWebSocketEventTimeout(timeoutMs)),
110
112
  getHibernatableWebSocketEventTimeout: Effect.sync(() =>
111
113
  state.getHibernatableWebSocketEventTimeout(),
112
114
  ),
113
- getTags: (ws: WebSocket) => Effect.sync(() => state.getTags(ws)),
115
+ getTags: (ws: DurableWebSocket) => Effect.sync(() => state.getTags(ws.raw)),
114
116
  abort: (reason?: string) => Effect.sync(() => state.abort(reason)),
115
117
  });
116
118
 
@@ -1,7 +1,82 @@
1
- import { Effect } from "effect";
1
+ import { Data, Effect, Option, Schema as S } from "effect";
2
2
 
3
3
  import { DurableObjectState } from "./DurableObjectState";
4
4
 
5
+ /** Data supported by Cloudflare websocket `send`. */
6
+ export type DurableWebSocketSendData = string | ArrayBuffer | ArrayBufferView;
7
+
8
+ /** Error raised when sending on a Durable Object websocket fails. */
9
+ export class DurableWebSocketSendError extends Data.TaggedError("DurableWebSocketSendError")<{
10
+ readonly cause: unknown;
11
+ }> {}
12
+
13
+ /** Error raised when closing a Durable Object websocket fails. */
14
+ export class DurableWebSocketCloseError extends Data.TaggedError("DurableWebSocketCloseError")<{
15
+ readonly cause: unknown;
16
+ }> {}
17
+
18
+ /** Error raised when serializing or deserializing a websocket attachment fails. */
19
+ export class DurableWebSocketAttachmentError extends Data.TaggedError(
20
+ "DurableWebSocketAttachmentError",
21
+ )<{
22
+ readonly operation: "serialize" | "deserialize";
23
+ readonly cause: unknown;
24
+ }> {}
25
+
26
+ /** Effect-native wrapper around a hibernatable Durable Object websocket. */
27
+ export interface DurableWebSocket<Attachment = unknown> {
28
+ /** Underlying Cloudflare websocket. */
29
+ readonly raw: WebSocket;
30
+ /** Sends a message through the socket. */
31
+ send(data: DurableWebSocketSendData): Effect.Effect<void, DurableWebSocketSendError>;
32
+ /** Closes the socket. */
33
+ close(code?: number, reason?: string): Effect.Effect<void, DurableWebSocketCloseError>;
34
+ /** Serializes hibernation attachment metadata onto the socket. */
35
+ serializeAttachment<A = Attachment>(
36
+ value: A,
37
+ ): Effect.Effect<void, DurableWebSocketAttachmentError>;
38
+ /** Deserializes hibernation attachment metadata from the socket. */
39
+ readonly deserializeAttachment: Effect.Effect<unknown, DurableWebSocketAttachmentError>;
40
+ }
41
+
42
+ const wrappers = new WeakMap<WebSocket, DurableWebSocket<unknown>>();
43
+
44
+ /** Wraps a native Cloudflare websocket in the Effect-native Durable Object API. */
45
+ export const fromWebSocket = <Attachment = unknown>(
46
+ raw: WebSocket,
47
+ ): DurableWebSocket<Attachment> => {
48
+ const existing = wrappers.get(raw);
49
+ if (existing !== undefined) {
50
+ return existing as DurableWebSocket<Attachment>;
51
+ }
52
+
53
+ const socket: DurableWebSocket<unknown> = {
54
+ raw,
55
+ send: (data) =>
56
+ Effect.try({
57
+ try: () => raw.send(data),
58
+ catch: (cause) => new DurableWebSocketSendError({ cause }),
59
+ }),
60
+ close: (code, reason) =>
61
+ Effect.try({
62
+ try: () => raw.close(code, reason),
63
+ catch: (cause) => new DurableWebSocketCloseError({ cause }),
64
+ }),
65
+ serializeAttachment: (value) =>
66
+ Effect.try({
67
+ try: () => raw.serializeAttachment(value),
68
+ catch: (cause) => new DurableWebSocketAttachmentError({ operation: "serialize", cause }),
69
+ }),
70
+ deserializeAttachment: Effect.try({
71
+ try: () => raw.deserializeAttachment(),
72
+ catch: (cause) => new DurableWebSocketAttachmentError({ operation: "deserialize", cause }),
73
+ }),
74
+ };
75
+
76
+ wrappers.set(raw, socket);
77
+ return socket as DurableWebSocket<Attachment>;
78
+ };
79
+
5
80
  /**
6
81
  * Options for accepting an incoming websocket request in a Durable Object.
7
82
  */
@@ -15,9 +90,9 @@ export interface AcceptUpgradeOptions<Attachment = unknown> {
15
90
  /**
16
91
  * Result of a websocket upgrade accepted by {@link acceptUpgrade}.
17
92
  */
18
- export interface AcceptedUpgrade {
93
+ export interface AcceptedUpgrade<Attachment = unknown> {
19
94
  readonly client: WebSocket;
20
- readonly server: WebSocket;
95
+ readonly server: DurableWebSocket<Attachment>;
21
96
  readonly response: Response;
22
97
  }
23
98
 
@@ -35,15 +110,15 @@ export interface AcceptedUpgrade {
35
110
  */
36
111
  export const acceptUpgrade = <Attachment = unknown>(
37
112
  options: AcceptUpgradeOptions<Attachment> = {},
38
- ): Effect.Effect<AcceptedUpgrade, never, DurableObjectState> =>
113
+ ): Effect.Effect<AcceptedUpgrade<Attachment>, never, DurableObjectState> =>
39
114
  Effect.gen(function* () {
40
115
  const state = yield* DurableObjectState;
41
116
  const pair = new WebSocketPair();
42
117
  const client = pair[0];
43
- const server = pair[1];
118
+ const server = fromWebSocket<Attachment>(pair[1]);
44
119
 
45
120
  if (options.attachment !== undefined) {
46
- server.serializeAttachment(options.attachment);
121
+ server.raw.serializeAttachment(options.attachment);
47
122
  }
48
123
 
49
124
  yield* state.acceptWebSocket(
@@ -60,3 +135,137 @@ export const acceptUpgrade = <Attachment = unknown>(
60
135
  }),
61
136
  };
62
137
  });
138
+
139
+ export type AttachmentInvalidPolicy = "ignore" | "ignore-and-close" | "fail";
140
+
141
+ export interface AttachmentRehydrateOptions {
142
+ /** Optional Durable Object websocket tag filter. */
143
+ readonly tag?: string | undefined;
144
+ /** Invalid attachment behavior. Defaults to skipping invalid sockets. */
145
+ readonly onInvalid?: AttachmentInvalidPolicy | undefined;
146
+ }
147
+
148
+ export interface RehydratedDurableWebSocket<Attachment> {
149
+ readonly socket: DurableWebSocket<Attachment>;
150
+ readonly attachment: Attachment;
151
+ }
152
+
153
+ export interface DurableWebSocketAttachment<Attachment, Encoded> {
154
+ serialize(
155
+ socket: DurableWebSocket<unknown>,
156
+ value: Attachment,
157
+ ): Effect.Effect<void, DurableWebSocketAttachmentError>;
158
+ deserialize(
159
+ socket: DurableWebSocket<unknown>,
160
+ ): Effect.Effect<Option.Option<Attachment>, DurableWebSocketAttachmentError>;
161
+ rehydrate(
162
+ options?: AttachmentRehydrateOptions,
163
+ ): Effect.Effect<
164
+ Array<RehydratedDurableWebSocket<Attachment>>,
165
+ DurableWebSocketAttachmentError,
166
+ DurableObjectState
167
+ >;
168
+ readonly schema: S.Codec<Attachment, Encoded, never, never>;
169
+ }
170
+
171
+ /** Creates typed attachment helpers for accepted and rehydrated sockets. */
172
+ export const attachment = <const AttachmentSchema extends S.Codec<any, any, never, never>>(
173
+ schema: AttachmentSchema,
174
+ ): DurableWebSocketAttachment<
175
+ S.Schema.Type<AttachmentSchema>,
176
+ S.Codec.Encoded<AttachmentSchema>
177
+ > => {
178
+ type Attachment = S.Schema.Type<AttachmentSchema>;
179
+ type Encoded = S.Codec.Encoded<AttachmentSchema>;
180
+
181
+ const serialize = (socket: DurableWebSocket<unknown>, value: Attachment) =>
182
+ S.encodeEffect(schema)(value).pipe(
183
+ Effect.mapError(
184
+ (cause) => new DurableWebSocketAttachmentError({ operation: "serialize", cause }),
185
+ ),
186
+ Effect.flatMap((encoded) => socket.serializeAttachment(encoded as Encoded)),
187
+ );
188
+
189
+ const deserialize = (socket: DurableWebSocket<unknown>) =>
190
+ Effect.gen(function* () {
191
+ const value = yield* socket.deserializeAttachment;
192
+ if (value == null) {
193
+ return Option.none<Attachment>();
194
+ }
195
+
196
+ return Option.some(
197
+ yield* S.decodeUnknownEffect(schema)(value).pipe(
198
+ Effect.mapError(
199
+ (cause) => new DurableWebSocketAttachmentError({ operation: "deserialize", cause }),
200
+ ),
201
+ ),
202
+ );
203
+ });
204
+
205
+ const rehydrate = (options: AttachmentRehydrateOptions = {}) =>
206
+ Effect.gen(function* () {
207
+ const state = yield* DurableObjectState;
208
+ const sockets = yield* state.getWebSockets(options.tag);
209
+ const restored: Array<RehydratedDurableWebSocket<Attachment>> = [];
210
+ const onInvalid = options.onInvalid ?? "ignore";
211
+
212
+ for (const socket of sockets) {
213
+ const decoded = yield* deserialize(socket).pipe(
214
+ Effect.match({
215
+ onFailure: (error) => ({ _tag: "Failure" as const, error }),
216
+ onSuccess: (value) => ({ _tag: "Success" as const, value }),
217
+ }),
218
+ );
219
+
220
+ if (decoded._tag === "Success" && Option.isSome(decoded.value)) {
221
+ restored.push({ socket, attachment: decoded.value.value });
222
+ continue;
223
+ }
224
+
225
+ if (decoded._tag === "Failure" && onInvalid === "fail") {
226
+ return yield* Effect.fail(decoded.error);
227
+ }
228
+
229
+ if (decoded._tag === "Failure" && onInvalid === "ignore-and-close") {
230
+ yield* socket.close(1008, "invalid websocket attachment").pipe(Effect.ignore);
231
+ }
232
+ }
233
+
234
+ return restored;
235
+ });
236
+
237
+ return { serialize, deserialize, rehydrate, schema };
238
+ };
239
+
240
+ export interface DurableWebSocketHandlers<R = never, E = unknown> {
241
+ readonly message?: (
242
+ socket: DurableWebSocket,
243
+ message: string | ArrayBuffer,
244
+ ) => Effect.Effect<void, E, R>;
245
+ readonly close?: (
246
+ socket: DurableWebSocket,
247
+ code: number,
248
+ reason: string,
249
+ wasClean: boolean,
250
+ ) => Effect.Effect<void, E, R>;
251
+ readonly error?: (socket: DurableWebSocket, error: unknown) => Effect.Effect<void, E, R>;
252
+ }
253
+
254
+ /** Adapts wrapped websocket lifecycle handlers to `DurableObject.make` raw callbacks. */
255
+ export const handlers = <R = never, E = unknown>(options: DurableWebSocketHandlers<R, E>) => ({
256
+ webSocketMessage:
257
+ options.message === undefined
258
+ ? undefined
259
+ : (socket: WebSocket, message: string | ArrayBuffer) =>
260
+ options.message?.(fromWebSocket(socket), message) ?? Effect.void,
261
+ webSocketClose:
262
+ options.close === undefined
263
+ ? undefined
264
+ : (socket: WebSocket, code: number, reason: string, wasClean: boolean) =>
265
+ options.close?.(fromWebSocket(socket), code, reason, wasClean) ?? Effect.void,
266
+ webSocketError:
267
+ options.error === undefined
268
+ ? undefined
269
+ : (socket: WebSocket, error: unknown) =>
270
+ options.error?.(fromWebSocket(socket), error) ?? Effect.void,
271
+ });
package/src/Queue.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { Data, Effect, type Scope } from "effect";
2
+
3
+ import type { ExecutionContext, WorkerContext } from "./Worker";
4
+ import type { WorkerEnvironment } from "./Environment";
5
+ import * as QueueDefinition from "./QueueDefinition";
6
+
7
+ export interface QueueMessage<Body> {
8
+ readonly raw: globalThis.Message<unknown>;
9
+ readonly id: string;
10
+ readonly timestamp: Date;
11
+ readonly body: Body;
12
+ readonly attempts: number;
13
+ readonly ack: Effect.Effect<void>;
14
+ readonly retry: (options?: globalThis.QueueRetryOptions) => Effect.Effect<void>;
15
+ }
16
+
17
+ export interface QueueBatch<Body> {
18
+ readonly raw: globalThis.MessageBatch<unknown>;
19
+ readonly messages: ReadonlyArray<QueueMessage<Body>>;
20
+ readonly queue: string;
21
+ readonly metadata: globalThis.MessageBatchMetadata;
22
+ readonly ackAll: Effect.Effect<void>;
23
+ readonly retryAll: (options?: globalThis.QueueRetryOptions) => Effect.Effect<void>;
24
+ }
25
+
26
+ export class QueueMessageDecodeError extends Data.TaggedError("QueueMessageDecodeError")<{
27
+ readonly queue: string;
28
+ readonly messageId: string;
29
+ readonly index: number;
30
+ readonly cause: unknown;
31
+ }> {}
32
+
33
+ type RuntimeContext<ROut> = ExecutionContext | WorkerContext | WorkerEnvironment | ROut;
34
+
35
+ type QueueHandlerContext<ROut> = RuntimeContext<ROut> | Scope.Scope;
36
+
37
+ export type QueueHandler<ROut, Body = unknown> = (
38
+ batch: QueueBatch<Body>,
39
+ ) => Effect.Effect<void, unknown, QueueHandlerContext<ROut>>;
40
+
41
+ export interface QueueOptions<ROut, Body = unknown> {
42
+ readonly queue: QueueHandler<ROut, Body>;
43
+ }
44
+
45
+ export const fromMessage = <Body>(message: globalThis.Message<unknown>, body: Body) => ({
46
+ raw: message,
47
+ id: message.id,
48
+ timestamp: message.timestamp,
49
+ body,
50
+ attempts: message.attempts,
51
+ ack: Effect.sync(() => message.ack()),
52
+ retry: (options?: globalThis.QueueRetryOptions) => Effect.sync(() => message.retry(options)),
53
+ });
54
+
55
+ export const fromMessageBatch = <Body>(
56
+ batch: globalThis.MessageBatch<unknown>,
57
+ messages: ReadonlyArray<QueueMessage<Body>>,
58
+ ): QueueBatch<Body> => ({
59
+ raw: batch,
60
+ messages,
61
+ queue: batch.queue,
62
+ metadata: batch.metadata,
63
+ ackAll: Effect.sync(() => batch.ackAll()),
64
+ retryAll: (options?: globalThis.QueueRetryOptions) => Effect.sync(() => batch.retryAll(options)),
65
+ });
66
+
67
+ export const decodeBatch = <Body>(
68
+ batch: globalThis.MessageBatch<unknown>,
69
+ decodeBody: (body: unknown) => Effect.Effect<Body, unknown>,
70
+ ): Effect.Effect<QueueBatch<Body>, QueueMessageDecodeError> =>
71
+ Effect.gen(function* () {
72
+ const messages: Array<QueueMessage<Body>> = [];
73
+
74
+ for (let index = 0; index < batch.messages.length; index++) {
75
+ const message = batch.messages[index];
76
+ const body = yield* decodeBody(message.body).pipe(
77
+ Effect.mapError(
78
+ (cause) =>
79
+ new QueueMessageDecodeError({
80
+ queue: batch.queue,
81
+ messageId: message.id,
82
+ index,
83
+ cause,
84
+ }),
85
+ ),
86
+ );
87
+
88
+ messages.push(fromMessage(message, body));
89
+ }
90
+
91
+ return fromMessageBatch(batch, messages);
92
+ });
93
+
94
+ export const Tag = QueueDefinition.Tag;
95
+
96
+ export const implement = QueueDefinition.implement;
97
+
98
+ export type Definition<
99
+ Id extends string = string,
100
+ Message extends QueueDefinition.Definition.Any["message"] =
101
+ QueueDefinition.Definition.Any["message"],
102
+ > = QueueDefinition.Definition<Id, Message>;
103
+
104
+ export namespace Definition {
105
+ export type Any = QueueDefinition.Definition.Any;
106
+ }
107
+
108
+ export type Handler<ROut, Self extends QueueDefinition.Definition.Any> = QueueDefinition.Handler<
109
+ ROut,
110
+ Self
111
+ >;
112
+
113
+ export type Options<ROut, Self extends QueueDefinition.Definition.Any> = QueueDefinition.Options<
114
+ ROut,
115
+ Self
116
+ >;
@@ -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
+ };