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/CHANGELOG.md +21 -0
- package/README.md +128 -1
- package/dist/index.d.mts +901 -308
- package/dist/index.mjs +613 -124
- package/package.json +3 -2
- package/src/DurableObjectRpcWebSocket.ts +21 -17
- package/src/DurableObjectState.ts +12 -10
- package/src/DurableObjectWebSocket.ts +215 -6
- package/src/Queue.ts +116 -0
- package/src/QueueBinding.ts +131 -0
- package/src/QueueDefinition.ts +129 -0
- package/src/Worker.ts +57 -68
- package/src/WorkerDefinition.ts +6 -0
- package/src/Workflow.ts +279 -0
- package/src/WorkflowBinding.ts +254 -0
- package/src/WorkflowDefinition.ts +167 -0
- package/src/index.ts +6 -0
- package/src/internal/WorkerContext.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-cf",
|
|
3
|
-
"version": "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.
|
|
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:
|
|
29
|
-
readonly message: (
|
|
30
|
-
|
|
31
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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<
|
|
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:
|
|
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:
|
|
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:
|
|
101
|
-
Effect.sync(() => state.acceptWebSocket(ws, tags)),
|
|
102
|
-
getWebSockets: (tag?: string) =>
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
+
};
|