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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-cf",
3
- "version": "0.1.0",
3
+ "version": "0.3.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 { Effect, Layer, ManagedRuntime, type Scope } from "effect";
4
4
  import { NativeRequest } from "./Worker";
5
5
  import { WorkerEnvironment, type WorkerEnv } from "./Environment";
6
6
  import { DurableObjectState, fromDurableObjectState } from "./DurableObjectState";
7
+ import { fromWebSocket, type DurableWebSocket } from "./DurableObjectWebSocket";
7
8
  import * as DurableObjectDefinition from "./DurableObjectDefinition";
8
9
  import * as Entrypoint from "./internal/Entrypoint";
9
10
 
@@ -84,17 +85,17 @@ export interface DurableObjectOptions<ROut, Rpc extends DurableObjectRpc<ROut>>
84
85
  alarmInfo?: globalThis.AlarmInvocationInfo,
85
86
  ) => Effect.Effect<void, unknown, HandlerContext<ROut>>;
86
87
  readonly webSocketMessage?: (
87
- socket: WebSocket,
88
+ socket: DurableWebSocket,
88
89
  message: string | ArrayBuffer,
89
90
  ) => Effect.Effect<void, unknown, HandlerContext<ROut>>;
90
91
  readonly webSocketClose?: (
91
- socket: WebSocket,
92
+ socket: DurableWebSocket,
92
93
  code: number,
93
94
  reason: string,
94
95
  wasClean: boolean,
95
96
  ) => Effect.Effect<void, unknown, HandlerContext<ROut>>;
96
97
  readonly webSocketError?: (
97
- socket: WebSocket,
98
+ socket: DurableWebSocket,
98
99
  error: unknown,
99
100
  ) => Effect.Effect<void, unknown, HandlerContext<ROut>>;
100
101
  }
@@ -172,7 +173,7 @@ export const make = <
172
173
 
173
174
  webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise<void> | void {
174
175
  if (options.webSocketMessage !== undefined) {
175
- return this[RunSymbol](options.webSocketMessage(socket, message));
176
+ return this[RunSymbol](options.webSocketMessage(fromWebSocket(socket), message));
176
177
  }
177
178
  }
178
179
 
@@ -183,13 +184,15 @@ export const make = <
183
184
  wasClean: boolean,
184
185
  ): Promise<void> | void {
185
186
  if (options.webSocketClose !== undefined) {
186
- return this[RunSymbol](options.webSocketClose(socket, code, reason, wasClean));
187
+ return this[RunSymbol](
188
+ options.webSocketClose(fromWebSocket(socket), code, reason, wasClean),
189
+ );
187
190
  }
188
191
  }
189
192
 
190
193
  webSocketError(socket: WebSocket, error: unknown): Promise<void> | void {
191
194
  if (options.webSocketError !== undefined) {
192
- return this[RunSymbol](options.webSocketError(socket, error));
195
+ return this[RunSymbol](options.webSocketError(fromWebSocket(socket), error));
193
196
  }
194
197
  }
195
198
  }
@@ -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,125 @@ 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
+ /** Maps compact websocket lifecycle handler names to `DurableObject.make` options. */
255
+ export const handlers = <R = never, E = unknown>(options: DurableWebSocketHandlers<R, E>) => ({
256
+ webSocketMessage: options.message,
257
+ webSocketClose: options.close,
258
+ webSocketError: options.error,
259
+ });
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
+ >;