@gjermundgaraba/effect-actions-cloudflare 0.0.0-stage → 0.21.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/LICENSE +21 -0
- package/README.md +54 -2
- package/dist/ActionDurableObject.d.mts +240 -0
- package/dist/ActionDurableObject.mjs +190 -0
- package/dist/alarm.d.mts +33 -0
- package/dist/alarm.mjs +130 -0
- package/dist/batches.mjs +10 -0
- package/dist/calls.d.mts +9 -0
- package/dist/calls.mjs +92 -0
- package/dist/events.mjs +131 -0
- package/dist/fetcher.d.mts +17 -0
- package/dist/fetcher.mjs +19 -0
- package/dist/frames.mjs +33 -0
- package/dist/idempotency.mjs +40 -0
- package/dist/placed.d.mts +47 -0
- package/dist/placed.mjs +53 -0
- package/dist/sockets.d.mts +115 -0
- package/dist/sockets.mjs +243 -0
- package/dist/state.d.mts +18 -0
- package/dist/state.mjs +18 -0
- package/dist/stub.d.mts +59 -0
- package/dist/stub.mjs +75 -0
- package/dist/subscriptions.mjs +104 -0
- package/dist/tasks.d.mts +24 -0
- package/dist/tasks.mjs +31 -0
- package/dist/webhooks.d.mts +36 -0
- package/dist/webhooks.mjs +108 -0
- package/docs/ActionDurableObject-events.md +153 -0
- package/docs/ActionDurableObject-failures.md +18 -0
- package/docs/ActionDurableObject-own-class.md +9 -0
- package/docs/ActionDurableObject-placement.md +41 -0
- package/docs/ActionDurableObject-stub.md +135 -0
- package/docs/ActionDurableObject-testing.md +35 -0
- package/docs/ActionDurableObject.md +235 -0
- package/docs/README.md +28 -0
- package/examples/chat-rooms/README.md +97 -0
- package/examples/chat-rooms/node_modules/.vite/vitest/CACHEDIR.TAG +3 -0
- package/examples/chat-rooms/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/examples/chat-rooms/src/Api.ts +14 -0
- package/examples/chat-rooms/src/Rpc.ts +22 -0
- package/examples/chat-rooms/src/browser.ts +107 -0
- package/examples/chat-rooms/src/chat/Chat.ts +95 -0
- package/examples/chat-rooms/src/chat/activity.ts +16 -0
- package/examples/chat-rooms/src/chat/authorize.ts +22 -0
- package/examples/chat-rooms/src/chat/events.ts +64 -0
- package/examples/chat-rooms/src/chat/handlers.ts +38 -0
- package/examples/chat-rooms/src/chat/room.ts +12 -0
- package/examples/chat-rooms/src/chat/store.ts +25 -0
- package/examples/chat-rooms/src/env.d.ts +12 -0
- package/examples/chat-rooms/src/identity/Identity.ts +21 -0
- package/examples/chat-rooms/src/identity/members.ts +8 -0
- package/examples/chat-rooms/src/identity/verifier.ts +24 -0
- package/examples/chat-rooms/src/lobby/Calls.ts +14 -0
- package/examples/chat-rooms/src/lobby/Lobby.ts +32 -0
- package/examples/chat-rooms/src/lobby/events.ts +17 -0
- package/examples/chat-rooms/src/lobby/handlers.ts +34 -0
- package/examples/chat-rooms/src/lobby/host.ts +10 -0
- package/examples/chat-rooms/src/lobby/store.ts +18 -0
- package/examples/chat-rooms/src/platform/kv-previews.ts +35 -0
- package/examples/chat-rooms/src/platform/lobby-stubs.ts +46 -0
- package/examples/chat-rooms/src/platform/sql-store.ts +111 -0
- package/examples/chat-rooms/src/rooms-entrypoint.ts +33 -0
- package/examples/chat-rooms/src/routes.ts +89 -0
- package/examples/chat-rooms/src/worker.ts +99 -0
- package/examples/chat-rooms/test/platform/sql-store.test.ts +132 -0
- package/examples/chat-rooms/test/support/socket.ts +70 -0
- package/examples/chat-rooms/test/worker.test.ts +572 -0
- package/examples/chat-rooms/tsconfig.json +22 -0
- package/examples/chat-rooms/vite.config.ts +19 -0
- package/examples/chat-rooms/wrangler.jsonc +15 -0
- package/package.json +49 -3
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
3
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
5
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
6
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
7
|
+
import { evictDurableObject, runInDurableObject } from "cloudflare:test";
|
|
8
|
+
import { env, exports } from "cloudflare:workers";
|
|
9
|
+
import type { Schema } from "effect";
|
|
10
|
+
import { Effect, Schedule, Stream } from "effect";
|
|
11
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
12
|
+
import { TestClock } from "effect/testing";
|
|
13
|
+
|
|
14
|
+
import { LobbyHttp, RoomHttp } from "../src/Api.js";
|
|
15
|
+
import { followLobby, openRoom } from "../src/browser.js";
|
|
16
|
+
import { MessagePosted, PostMessage } from "../src/chat/Chat.js";
|
|
17
|
+
import type { RoomName } from "../src/chat/Chat.js";
|
|
18
|
+
import { Login } from "../src/identity/Identity.js";
|
|
19
|
+
import { RoomRpc, unsupportedProtocol } from "../src/Rpc.js";
|
|
20
|
+
import { pageSocket, roomSocket } from "./support/socket.js";
|
|
21
|
+
|
|
22
|
+
const day = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
// Every request goes through the Worker's own `fetch`, as a deployed one would get it.
|
|
25
|
+
const worker = ActionDurableObject.layerHttpClient(exports.default);
|
|
26
|
+
|
|
27
|
+
// The rooms' JSON routes, the Worker's: each call names its room.
|
|
28
|
+
const jsonAs = (token?: string) =>
|
|
29
|
+
ActionHttp.client(RoomHttp, {
|
|
30
|
+
baseUrl: "https://chat.example",
|
|
31
|
+
...(token === undefined
|
|
32
|
+
? {}
|
|
33
|
+
: {
|
|
34
|
+
transformClient: HttpClient.mapRequest(
|
|
35
|
+
HttpClientRequest.bearerToken(token)
|
|
36
|
+
),
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// An agent's one connection to the Worker's MCP endpoint, reaching every room.
|
|
41
|
+
const agentAs = (token: string) =>
|
|
42
|
+
ActionMcp.client([PostMessage, MessagePosted], {
|
|
43
|
+
url: "https://chat.example/mcp",
|
|
44
|
+
authentication: Login,
|
|
45
|
+
transformClient: HttpClient.mapRequest(
|
|
46
|
+
HttpClientRequest.bearerToken(token)
|
|
47
|
+
),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// What the runtime does after a call answers, such as a room's alarm, is waited for in real time:
|
|
51
|
+
// a test using this runs under `it.live`, as under the test clock its sleeps never pass.
|
|
52
|
+
const eventually = <A, E>(
|
|
53
|
+
check: Effect.Effect<A, E>,
|
|
54
|
+
until: (value: A) => boolean
|
|
55
|
+
) =>
|
|
56
|
+
check.pipe(
|
|
57
|
+
Effect.repeat({ until, schedule: Schedule.spaced("10 millis") }),
|
|
58
|
+
Effect.timeout("5 seconds")
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const arrivedIn = (received: readonly unknown[]) => (count: number) =>
|
|
62
|
+
eventually(
|
|
63
|
+
Effect.sync(() => received.length),
|
|
64
|
+
(length) => length >= count
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const pageIn = (
|
|
68
|
+
room: RoomName,
|
|
69
|
+
token: string,
|
|
70
|
+
headers: Record<string, string> = {}
|
|
71
|
+
) =>
|
|
72
|
+
Effect.flatMap(roomSocket(room, headers), ({ protocol }) =>
|
|
73
|
+
Effect.provideContext(openRoom(room, token), protocol)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const generalIn = (
|
|
77
|
+
previews: ReadonlyArray<{ room: string; latestAt: number | null }>
|
|
78
|
+
) => previews.find(({ room }) => room === "general")?.latestAt;
|
|
79
|
+
|
|
80
|
+
const status = (path: string, body: Schema.Json = {}) =>
|
|
81
|
+
Effect.promise(
|
|
82
|
+
async () =>
|
|
83
|
+
await exports.default.fetch(`https://chat.example${path}`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"content-type": "application/json",
|
|
87
|
+
authorization: "Bearer ada",
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
})
|
|
91
|
+
).pipe(Effect.map((response) => response.status));
|
|
92
|
+
|
|
93
|
+
const readAll = (room: string) => ({ room, after: 0 });
|
|
94
|
+
|
|
95
|
+
// First in the file, whose Worker module is its own: no request has reached the lobby before, so
|
|
96
|
+
// a first request ending without I/O is the one that would strand a client built inside it, as
|
|
97
|
+
// `ActionDurableObject.layerStubClient`'s gotchas say.
|
|
98
|
+
describe("the Worker's handler, built as the module loads", () => {
|
|
99
|
+
it.effect(
|
|
100
|
+
"answers a first request ending without I/O, then a call reaching the lobby",
|
|
101
|
+
() =>
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
const missing = yield* Effect.promise(
|
|
104
|
+
async () =>
|
|
105
|
+
await exports.default.fetch("https://chat.example/missing")
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const lobby = yield* ActionHttp.client(LobbyHttp, {
|
|
109
|
+
baseUrl: "https://chat.example",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const { previews } = yield* lobby.previews();
|
|
113
|
+
|
|
114
|
+
expect(missing.status).toBe(404);
|
|
115
|
+
expect(previews.map(({ room }) => room)).toStrictEqual([
|
|
116
|
+
"general",
|
|
117
|
+
"design",
|
|
118
|
+
]);
|
|
119
|
+
}).pipe(Effect.provide(worker))
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("the Worker and its rooms", () => {
|
|
124
|
+
it.effect(
|
|
125
|
+
"serves a room's page over its socket, the token on each message",
|
|
126
|
+
() =>
|
|
127
|
+
Effect.gen(function* () {
|
|
128
|
+
const room = yield* pageIn("design", "ada");
|
|
129
|
+
const posted = yield* room.post("Mock-ups are up.");
|
|
130
|
+
|
|
131
|
+
expect(yield* room.read(posted.id - 1)).toStrictEqual([posted]);
|
|
132
|
+
expect(yield* room.remove(posted.id)).toBe("removed");
|
|
133
|
+
}).pipe(Effect.scoped)
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
it.effect(
|
|
137
|
+
"reads the messages posted after an id, over a socket or JSON",
|
|
138
|
+
() =>
|
|
139
|
+
Effect.gen(function* () {
|
|
140
|
+
const reader = yield* pageIn("general", "ada");
|
|
141
|
+
const writer = yield* pageIn("general", "ada");
|
|
142
|
+
const json = yield* jsonAs("ada");
|
|
143
|
+
const first = yield* writer.post("Over a socket.");
|
|
144
|
+
|
|
145
|
+
const second = yield* json.postMessage({
|
|
146
|
+
room: "general",
|
|
147
|
+
text: "Over the JSON routes.",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(yield* reader.read(first.id - 1)).toStrictEqual([first, second]);
|
|
151
|
+
expect(yield* reader.read(first.id)).toStrictEqual([second]);
|
|
152
|
+
}).pipe(Effect.scoped, Effect.provide(worker))
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
it.effect(
|
|
156
|
+
"posts a message sent again with its request once, answering the repeat as the first",
|
|
157
|
+
() =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const reader = yield* pageIn("general", "ada");
|
|
160
|
+
const json = yield* jsonAs("ada");
|
|
161
|
+
|
|
162
|
+
const sent = {
|
|
163
|
+
room: "general",
|
|
164
|
+
text: "Sent twice.",
|
|
165
|
+
request: crypto.randomUUID(),
|
|
166
|
+
} as const;
|
|
167
|
+
|
|
168
|
+
const first = yield* json.postMessage(sent);
|
|
169
|
+
|
|
170
|
+
expect(yield* json.postMessage(sent)).toStrictEqual(first);
|
|
171
|
+
expect(yield* reader.read(first.id - 1)).toStrictEqual([first]);
|
|
172
|
+
}).pipe(Effect.scoped, Effect.provide(worker))
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
it.effect(
|
|
176
|
+
"refuses a page built for another version of the rpcs, closing its socket with the code it reads, which stops its client connecting again",
|
|
177
|
+
() =>
|
|
178
|
+
Effect.gen(function* () {
|
|
179
|
+
const { protocol, connects } = yield* roomSocket("general", {}, "0");
|
|
180
|
+
|
|
181
|
+
const page = yield* Effect.provideContext(
|
|
182
|
+
openRoom("general", "ada"),
|
|
183
|
+
protocol
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const refused = yield* Effect.flip(page.read(0));
|
|
187
|
+
|
|
188
|
+
yield* TestClock.adjust("1 minute");
|
|
189
|
+
|
|
190
|
+
expect(refused).toMatchObject({
|
|
191
|
+
_tag: "RpcClientError",
|
|
192
|
+
reason: { _tag: "SocketCloseError", code: unsupportedProtocol },
|
|
193
|
+
});
|
|
194
|
+
expect(connects()).toBe(1);
|
|
195
|
+
}).pipe(Effect.scoped)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
it.effect(
|
|
199
|
+
"authenticates a socket's messages by the token of its upgrade, after eviction",
|
|
200
|
+
() =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const { protocol } = yield* roomSocket("design", {
|
|
203
|
+
authorization: "Bearer ada",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const client = yield* Effect.provideContext(
|
|
207
|
+
ActionRpc.client(RoomRpc),
|
|
208
|
+
protocol
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const posted = yield* client.postMessage({
|
|
212
|
+
room: "design",
|
|
213
|
+
text: "Before the eviction.",
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
yield* Effect.promise(async () => {
|
|
217
|
+
await evictDurableObject(env.CHAT_ROOM.getByName("design"));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const read = yield* client.readMessages({
|
|
221
|
+
room: "design",
|
|
222
|
+
after: posted.id - 1,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const anonymous = yield* jsonAs().pipe(
|
|
226
|
+
Effect.flatMap((json) =>
|
|
227
|
+
Effect.flip(
|
|
228
|
+
json.postMessage({ room: "design", text: "Anonymous." })
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
expect(read.messages).toStrictEqual([posted]);
|
|
234
|
+
expect(anonymous).toBeInstanceOf(Action.Unauthenticated);
|
|
235
|
+
}).pipe(Effect.scoped, Effect.provide(worker))
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
it.live(
|
|
239
|
+
"follows a room through MessagePosted from the last id a page read, across the object's eviction",
|
|
240
|
+
() =>
|
|
241
|
+
Effect.gen(function* () {
|
|
242
|
+
const reader = yield* pageIn("design", "ada");
|
|
243
|
+
const writer = yield* pageIn("design", "ada");
|
|
244
|
+
const json = yield* jsonAs("ada");
|
|
245
|
+
const before = yield* writer.post("Before the page opened.");
|
|
246
|
+
const read = yield* reader.read(before.id - 1);
|
|
247
|
+
const after = read.at(-1)?.id ?? 0;
|
|
248
|
+
|
|
249
|
+
const received: unknown[] = [];
|
|
250
|
+
|
|
251
|
+
yield* reader.messages(after).pipe(
|
|
252
|
+
Stream.runForEach((item) => Effect.sync(() => received.push(item))),
|
|
253
|
+
Effect.forkScoped
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const arrived = arrivedIn(received);
|
|
257
|
+
|
|
258
|
+
const first = yield* writer.post("Read as the watch starts.");
|
|
259
|
+
|
|
260
|
+
yield* arrived(1);
|
|
261
|
+
|
|
262
|
+
const second = yield* writer.post(
|
|
263
|
+
"Over a socket, while the page watches."
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
yield* arrived(2);
|
|
267
|
+
yield* Effect.promise(async () => {
|
|
268
|
+
await evictDurableObject(env.CHAT_ROOM.getByName("design"));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const third = yield* json.postMessage({
|
|
272
|
+
room: "design",
|
|
273
|
+
text: "Over the JSON routes, after a sleep.",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
yield* arrived(3);
|
|
277
|
+
|
|
278
|
+
expect(read).toStrictEqual([before]);
|
|
279
|
+
expect(received).toMatchObject([
|
|
280
|
+
{ _tag: "Occurrence", data: first },
|
|
281
|
+
{ _tag: "Occurrence", data: second },
|
|
282
|
+
{ _tag: "Occurrence", data: third },
|
|
283
|
+
]);
|
|
284
|
+
}).pipe(Effect.scoped, Effect.provide(worker))
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
it.live(
|
|
288
|
+
"serves every room over one MCP connection: a post and a poll in each, across an eviction, and a member outside a room refused",
|
|
289
|
+
() =>
|
|
290
|
+
Effect.gen(function* () {
|
|
291
|
+
const ada = yield* agentAs("ada");
|
|
292
|
+
|
|
293
|
+
const general = yield* ada.postMessage({
|
|
294
|
+
room: "general",
|
|
295
|
+
text: "Polled in general.",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const design = yield* ada.postMessage({
|
|
299
|
+
room: "design",
|
|
300
|
+
text: "Polled in design.",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const polled = (room: RoomName, after: number) =>
|
|
304
|
+
ada
|
|
305
|
+
.watch(
|
|
306
|
+
MessagePosted,
|
|
307
|
+
{ room },
|
|
308
|
+
{ mode: "poll", cursor: String(after) }
|
|
309
|
+
)
|
|
310
|
+
.pipe(
|
|
311
|
+
Stream.take(1),
|
|
312
|
+
Stream.runCollect,
|
|
313
|
+
Effect.timeout("5 seconds")
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const fromGeneral = yield* polled("general", general.id - 1);
|
|
317
|
+
|
|
318
|
+
yield* Effect.promise(async () => {
|
|
319
|
+
await evictDurableObject(env.CHAT_ROOM.getByName("design"));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const fromDesign = yield* polled("design", design.id - 1);
|
|
323
|
+
const grace = yield* agentAs("grace");
|
|
324
|
+
|
|
325
|
+
const refused = yield* Effect.flip(
|
|
326
|
+
grace.postMessage({ room: "design", text: "Not a member." })
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(fromGeneral).toMatchObject([
|
|
330
|
+
{ _tag: "Occurrence", data: general },
|
|
331
|
+
]);
|
|
332
|
+
expect(fromDesign).toMatchObject([
|
|
333
|
+
{ _tag: "Occurrence", data: design },
|
|
334
|
+
]);
|
|
335
|
+
expect(refused).toBeInstanceOf(Action.Forbidden);
|
|
336
|
+
}).pipe(Effect.scoped, Effect.provide(worker))
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
it.effect(
|
|
340
|
+
"refuses, in a room's object, a call on its socket naming another room",
|
|
341
|
+
() =>
|
|
342
|
+
Effect.gen(function* () {
|
|
343
|
+
const { protocol } = yield* roomSocket("design", {
|
|
344
|
+
authorization: "Bearer ada",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const client = yield* Effect.provideContext(
|
|
348
|
+
ActionRpc.client(RoomRpc),
|
|
349
|
+
protocol
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const refused = yield* Effect.flip(
|
|
353
|
+
client.postMessage({
|
|
354
|
+
room: "general",
|
|
355
|
+
text: "Through the wrong room.",
|
|
356
|
+
})
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(refused).toBeInstanceOf(Action.Forbidden);
|
|
360
|
+
expect(refused.message).toContain("names general");
|
|
361
|
+
}).pipe(Effect.scoped)
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
it.effect(
|
|
365
|
+
"refuses a member outside the room, on its socket and on its JSON routes",
|
|
366
|
+
() =>
|
|
367
|
+
Effect.gen(function* () {
|
|
368
|
+
const page = yield* pageIn("design", "grace");
|
|
369
|
+
const json = yield* jsonAs("grace");
|
|
370
|
+
|
|
371
|
+
expect(yield* Effect.flip(page.read(0))).toBeInstanceOf(
|
|
372
|
+
Action.Forbidden
|
|
373
|
+
);
|
|
374
|
+
expect(
|
|
375
|
+
yield* Effect.flip(Stream.runDrain(page.messages(0)))
|
|
376
|
+
).toBeInstanceOf(Action.Forbidden);
|
|
377
|
+
expect(
|
|
378
|
+
yield* Effect.flip(
|
|
379
|
+
json.postMessage({ room: "design", text: "Let me in." })
|
|
380
|
+
)
|
|
381
|
+
).toBeInstanceOf(Action.Forbidden);
|
|
382
|
+
}).pipe(Effect.scoped, Effect.provide(worker))
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
it.effect("refuses to delete another member's message", () =>
|
|
386
|
+
Effect.gen(function* () {
|
|
387
|
+
const ada = yield* pageIn("general", "ada");
|
|
388
|
+
const grace = yield* pageIn("general", "grace");
|
|
389
|
+
const posted = yield* ada.post("Mine.");
|
|
390
|
+
|
|
391
|
+
expect(yield* grace.remove(posted.id)).toBe("not-yours");
|
|
392
|
+
|
|
393
|
+
expect(yield* grace.read(posted.id - 1)).toStrictEqual([posted]);
|
|
394
|
+
}).pipe(Effect.scoped)
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
it.live(
|
|
398
|
+
"answers the lobby's previews as each room told its object, without what was said",
|
|
399
|
+
() =>
|
|
400
|
+
Effect.gen(function* () {
|
|
401
|
+
const json = yield* jsonAs("ada");
|
|
402
|
+
|
|
403
|
+
const posted = yield* json.postMessage({
|
|
404
|
+
room: "design",
|
|
405
|
+
text: "Seen from the lobby.",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const lobby = yield* ActionHttp.client(LobbyHttp, {
|
|
409
|
+
baseUrl: "https://chat.example",
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// The room tells the lobby from its `lobby` task, which the room's alarm runs once the
|
|
413
|
+
// post has answered: the lobby hears of it a moment later, not as the post returns.
|
|
414
|
+
const { previews } = yield* eventually(lobby.previews(), (answer) =>
|
|
415
|
+
answer.previews.some(
|
|
416
|
+
({ room, latestAt }) => room === "design" && latestAt === posted.at
|
|
417
|
+
)
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
expect(previews.map(({ room }) => room)).toStrictEqual([
|
|
421
|
+
"general",
|
|
422
|
+
"design",
|
|
423
|
+
]);
|
|
424
|
+
expect(previews).toContainEqual({
|
|
425
|
+
room: "design",
|
|
426
|
+
latestAt: posted.at,
|
|
427
|
+
});
|
|
428
|
+
}).pipe(Effect.provide(worker))
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
it.live(
|
|
432
|
+
"sends a page following the lobby, signed out, the previews at once, then whole at each change",
|
|
433
|
+
() =>
|
|
434
|
+
Effect.gen(function* () {
|
|
435
|
+
const general = yield* jsonAs("grace");
|
|
436
|
+
|
|
437
|
+
const { messages } = yield* general.readMessages({
|
|
438
|
+
room: "general",
|
|
439
|
+
after: 0,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const before = messages.at(-1)?.at ?? null;
|
|
443
|
+
const { protocol } = yield* pageSocket("/lobby");
|
|
444
|
+
|
|
445
|
+
const received: Array<
|
|
446
|
+
ReadonlyArray<{ room: string; latestAt: number | null }>
|
|
447
|
+
> = [];
|
|
448
|
+
|
|
449
|
+
yield* Effect.flatMap(followLobby, (previews) =>
|
|
450
|
+
Stream.runForEach(previews, (each) =>
|
|
451
|
+
Effect.sync(() => received.push(each))
|
|
452
|
+
)
|
|
453
|
+
).pipe(Effect.provideContext(protocol), Effect.forkScoped);
|
|
454
|
+
|
|
455
|
+
yield* arrivedIn(received)(1);
|
|
456
|
+
|
|
457
|
+
const posted = yield* general.postMessage({
|
|
458
|
+
room: "general",
|
|
459
|
+
text: "Seen by the lobby's pages.",
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
yield* arrivedIn(received)(2);
|
|
463
|
+
yield* general.deleteMessage({ room: "general", id: posted.id });
|
|
464
|
+
yield* arrivedIn(received)(3);
|
|
465
|
+
|
|
466
|
+
expect(received.map(generalIn)).toStrictEqual([
|
|
467
|
+
before,
|
|
468
|
+
posted.at,
|
|
469
|
+
before,
|
|
470
|
+
]);
|
|
471
|
+
expect(received.every((previews) => previews.length === 2)).toBe(true);
|
|
472
|
+
}).pipe(Effect.scoped, Effect.provide(worker))
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
it.live(
|
|
476
|
+
"answers another Worker's call of RoomsEntrypoint as the lobby keeps it, and refuses a room it does not list",
|
|
477
|
+
() =>
|
|
478
|
+
Effect.gen(function* () {
|
|
479
|
+
const posted = yield* (yield* jsonAs("grace")).postMessage({
|
|
480
|
+
room: "general",
|
|
481
|
+
text: "Seen from another Worker.",
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const refused = yield* Effect.flip(
|
|
485
|
+
Effect.tryPromise(
|
|
486
|
+
async () => await exports.RoomsEntrypoint.latestAt("kitchen")
|
|
487
|
+
)
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
// The lobby hears of the post from the room's `lobby` task, a moment after it answered.
|
|
491
|
+
const latestAt = yield* eventually(
|
|
492
|
+
Effect.promise(
|
|
493
|
+
async () => await exports.RoomsEntrypoint.latestAt("general")
|
|
494
|
+
),
|
|
495
|
+
(at) => at === posted.at
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
expect(latestAt).toBe(posted.at);
|
|
499
|
+
expect(String(refused.cause)).toContain(
|
|
500
|
+
'Expected "general" | "design"'
|
|
501
|
+
);
|
|
502
|
+
}).pipe(Effect.provide(worker))
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
it.effect(
|
|
506
|
+
"sends a page's socket alone on to its object, and answers every other path itself, a room it does not list an invalid input",
|
|
507
|
+
() =>
|
|
508
|
+
Effect.gen(function* () {
|
|
509
|
+
expect(yield* status("/api/readMessages", readAll("design"))).toBe(200);
|
|
510
|
+
expect(yield* status("/api/readMessages", readAll("unlisted"))).toBe(
|
|
511
|
+
400
|
|
512
|
+
);
|
|
513
|
+
expect(
|
|
514
|
+
yield* status("/rooms/design/api/readMessages", readAll("design"))
|
|
515
|
+
).toBe(404);
|
|
516
|
+
expect(yield* status("/rooms/unlisted/rpc")).toBe(404);
|
|
517
|
+
expect(yield* status("/rooms/design//elsewhere.example/rpc")).toBe(404);
|
|
518
|
+
})
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
it.live(
|
|
522
|
+
"expires a message a day old when the room's alarm runs, and sets the next",
|
|
523
|
+
() =>
|
|
524
|
+
Effect.gen(function* () {
|
|
525
|
+
const json = yield* jsonAs("ada");
|
|
526
|
+
const stub = env.CHAT_ROOM.getByName("design");
|
|
527
|
+
|
|
528
|
+
yield* Effect.promise(async () => {
|
|
529
|
+
await runInDurableObject(stub, (_, state) => {
|
|
530
|
+
state.storage.sql.exec("DELETE FROM messages");
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const old = yield* json.postMessage({
|
|
535
|
+
room: "design",
|
|
536
|
+
text: "From yesterday.",
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
yield* Effect.promise(async () => {
|
|
540
|
+
await runInDurableObject(stub, (_, state) => {
|
|
541
|
+
state.storage.sql.exec(
|
|
542
|
+
"UPDATE messages SET at = at - ? WHERE id = ?",
|
|
543
|
+
day,
|
|
544
|
+
old.id
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// The next post sets the alarm for the day-old message's expiry, already past, so the
|
|
550
|
+
// runtime runs it at once: the test waits until it has set the next, for the recent one's.
|
|
551
|
+
const recent = yield* json.postMessage({
|
|
552
|
+
room: "design",
|
|
553
|
+
text: "From today.",
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
yield* eventually(
|
|
557
|
+
Effect.promise(
|
|
558
|
+
async () =>
|
|
559
|
+
await runInDurableObject(
|
|
560
|
+
stub,
|
|
561
|
+
async (_, state) => await state.storage.getAlarm()
|
|
562
|
+
)
|
|
563
|
+
),
|
|
564
|
+
(alarm) => alarm === recent.at + day
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
expect(yield* (yield* pageIn("design", "ada")).read(0)).toStrictEqual([
|
|
568
|
+
recent,
|
|
569
|
+
]);
|
|
570
|
+
}).pipe(Effect.scoped, Effect.provide(worker))
|
|
571
|
+
);
|
|
572
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "esnext",
|
|
4
|
+
"lib": ["es2024"],
|
|
5
|
+
"moduleDetection": "force",
|
|
6
|
+
"module": "nodenext",
|
|
7
|
+
"moduleResolution": "nodenext",
|
|
8
|
+
"types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"],
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"noUncheckedIndexedAccess": true,
|
|
15
|
+
"exactOptionalPropertyTypes": true,
|
|
16
|
+
"noUnusedLocals": true,
|
|
17
|
+
"noUnusedParameters": true,
|
|
18
|
+
"noImplicitOverride": true,
|
|
19
|
+
"noFallthroughCasesInSwitch": true
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*.ts", "test/**/*.ts", "vite.config.ts"]
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cloudflareTest } from "@cloudflare/vitest-plugin";
|
|
2
|
+
import { defineConfig } from "vite-plus";
|
|
3
|
+
|
|
4
|
+
// Its tests run inside the Workers runtime, on the Worker wrangler.jsonc describes.
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [
|
|
7
|
+
cloudflareTest({
|
|
8
|
+
wrangler: { configPath: "./wrangler.jsonc" },
|
|
9
|
+
}),
|
|
10
|
+
],
|
|
11
|
+
test: {
|
|
12
|
+
include: ["test/**/*.test.ts"],
|
|
13
|
+
// Why: under @cloudflare/vitest-plugin 1.4.4, the warm-module response for a later file in a
|
|
14
|
+
// pool slot can exceed what the runner's control socket accepts, 41 MB in 709 entries
|
|
15
|
+
// measured on chat-rooms, which closes it with 1009 and loses the file as "Worker exited
|
|
16
|
+
// unexpectedly"; two small files alone did not trigger it.
|
|
17
|
+
fsModuleCache: false,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chat-rooms",
|
|
3
|
+
"main": "./src/worker.ts",
|
|
4
|
+
"compatibility_date": "2026-10-01",
|
|
5
|
+
"durable_objects": {
|
|
6
|
+
"bindings": [
|
|
7
|
+
{ "name": "CHAT_ROOM", "class_name": "ChatRoom" },
|
|
8
|
+
{ "name": "CHAT_LOBBY", "class_name": "ChatLobby" },
|
|
9
|
+
],
|
|
10
|
+
},
|
|
11
|
+
"migrations": [
|
|
12
|
+
{ "tag": "v1", "new_sqlite_classes": ["ChatRoom"] },
|
|
13
|
+
{ "tag": "v2", "new_sqlite_classes": ["ChatLobby"] },
|
|
14
|
+
],
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/effect-actions-cloudflare",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.21.0",
|
|
4
|
+
"description": "Serve effect-actions from Cloudflare Durable Objects, with Effect RPC over hibernating WebSockets.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cloudflare",
|
|
7
|
+
"durable-objects",
|
|
8
|
+
"effect",
|
|
9
|
+
"effect-ts",
|
|
10
|
+
"rpc",
|
|
11
|
+
"websocket",
|
|
12
|
+
"workers"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/gjermundgaraba/effect-actions/tree/main/packages/cloudflare#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/gjermundgaraba/effect-actions/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/gjermundgaraba/effect-actions.git",
|
|
22
|
+
"directory": "packages/cloudflare"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"docs",
|
|
27
|
+
"examples"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"exports": {
|
|
32
|
+
"./ActionDurableObject": "./dist/ActionDurableObject.mjs",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@cloudflare/vitest-plugin": "1.4.7",
|
|
40
|
+
"@cloudflare/workers-types": "5.20261025.1",
|
|
41
|
+
"@effect/vitest": "4.0.6",
|
|
42
|
+
"@gjermundgaraba/effect-actions": "0.21.0",
|
|
43
|
+
"effect": "4.0.6"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@gjermundgaraba/effect-actions": "^0.21.0",
|
|
47
|
+
"effect": "~4.0.5"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "vp pack"
|
|
51
|
+
}
|
|
6
52
|
}
|