@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,46 @@
|
|
|
1
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
2
|
+
import { env } from "cloudflare:workers";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
|
|
5
|
+
import { Activity } from "../chat/activity.js";
|
|
6
|
+
import { Room } from "../chat/room.js";
|
|
7
|
+
import { MessageStore } from "../chat/store.js";
|
|
8
|
+
import { LobbyCalls } from "../lobby/Calls.js";
|
|
9
|
+
import { LobbyHost } from "../lobby/host.js";
|
|
10
|
+
|
|
11
|
+
// The lobby is one object, named `lobby`: a new stub for each call, read from `env` as the call
|
|
12
|
+
// is sent, never as the module loads.
|
|
13
|
+
const lobbyStub = () => env.CHAT_LOBBY.getByName("lobby");
|
|
14
|
+
|
|
15
|
+
// The lobby's object as the Worker reaches it, for the previews: a lobby that cannot be reached
|
|
16
|
+
// is the deployment's failure, not the caller's, so it is a defect, the Worker answers 500, and
|
|
17
|
+
// the cause is in its logs.
|
|
18
|
+
export const layerLobbyHost = Layer.effect(LobbyHost)(
|
|
19
|
+
Effect.map(ActionDurableObject.client(LobbyCalls), (lobby) =>
|
|
20
|
+
LobbyHost.of({ previews: Effect.orDie(lobby(lobbyStub).previews()) })
|
|
21
|
+
)
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// The room's task, worker.ts's `lobby`: telling the lobby the room's latest activity. The room's
|
|
25
|
+
// alarm runs its tasks one run at a time, each waiting for the lobby's answer, and each run reads
|
|
26
|
+
// the activity as it is then: the lobby hears a room's reports in the order the room made them,
|
|
27
|
+
// so a report heard late never overwrites a later one, and the last it hears is the room's
|
|
28
|
+
// latest. A run repeated after a lost answer sends what the lobby holds, which `record` finds
|
|
29
|
+
// unchanged, notifying no page.
|
|
30
|
+
export const tellLobby = Effect.gen(function* () {
|
|
31
|
+
const room = yield* Room;
|
|
32
|
+
const latest = yield* MessageStore.use((store) => store.latest);
|
|
33
|
+
const lobby = yield* ActionDurableObject.client(LobbyCalls);
|
|
34
|
+
|
|
35
|
+
yield* lobby(lobbyStub).roomChanged({ room, latestAt: latest?.at ?? null });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// A room's activity, a startup service of the room's: each change runs the `lobby` task after it,
|
|
39
|
+
// on the room's alarm once the call has answered, and again until the lobby hears it, so a lobby
|
|
40
|
+
// that cannot be reached fails no post and misses no change. `runAfter` marks the task before the
|
|
41
|
+
// change and again after it, so a crash at any point leaves the task to the alarm.
|
|
42
|
+
export const layerActivity = Layer.effect(Activity)(
|
|
43
|
+
Effect.map(ActionDurableObject.Tasks, (tasks) =>
|
|
44
|
+
Activity.of({ changing: (change) => tasks.runAfter("lobby", change) })
|
|
45
|
+
)
|
|
46
|
+
);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
2
|
+
import { Clock, DateTime, Effect, Layer, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
import { Message, MessageNotFound, NotAuthor } from "../chat/Chat.js";
|
|
5
|
+
import { MessageStore } from "../chat/store.js";
|
|
6
|
+
|
|
7
|
+
const day = 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
const decodeMessage = Schema.decodeUnknownSync(Message);
|
|
10
|
+
|
|
11
|
+
const decodeSequence = Schema.decodeUnknownSync(
|
|
12
|
+
Schema.Array(Schema.Struct({ seq: Schema.Int }))
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// The room's messages in the object's own SQLite database, which outlives every instance: an
|
|
16
|
+
// evicted instance loses its memory, never these. The Layer runs each time an instance is
|
|
17
|
+
// constructed, so creating the table is idempotent.
|
|
18
|
+
export const layerSql = Layer.effect(MessageStore)(
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const { storage } = yield* ActionDurableObject.State;
|
|
21
|
+
const alarm = yield* ActionDurableObject.Alarm;
|
|
22
|
+
|
|
23
|
+
const query = (sql: string, ...bindings: ReadonlyArray<string | number>) =>
|
|
24
|
+
storage.sql
|
|
25
|
+
.exec(sql, ...bindings)
|
|
26
|
+
.toArray()
|
|
27
|
+
.map((row) => decodeMessage(row));
|
|
28
|
+
|
|
29
|
+
storage.sql.exec(
|
|
30
|
+
"CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)"
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Every post looks up the oldest message: indexed, it reads one row, where a scan would read
|
|
34
|
+
// the whole table, and SQLite storage is billed by rows read.
|
|
35
|
+
storage.sql.exec(
|
|
36
|
+
"CREATE INDEX IF NOT EXISTS messages_by_at ON messages (at)"
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// The room's alarm is due no later than the oldest message's expiry: `post` and `expire` set
|
|
40
|
+
// it for the oldest message, read from the table and set in one step, so posts running at
|
|
41
|
+
// once each set it for the oldest they see, and the last to set it has seen them all; reading
|
|
42
|
+
// the alarm first would let two posts both see none and the later one win. Removing the
|
|
43
|
+
// oldest message leaves its alarm, which then finds nothing due and moves on.
|
|
44
|
+
const alarmForOldest = Effect.suspend(() => {
|
|
45
|
+
const [oldest] = query("SELECT * FROM messages ORDER BY at LIMIT 1");
|
|
46
|
+
|
|
47
|
+
return oldest === undefined
|
|
48
|
+
? Effect.void
|
|
49
|
+
: alarm.set(DateTime.makeUnsafe(oldest.at + day));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return MessageStore.of({
|
|
53
|
+
latest: Effect.sync(
|
|
54
|
+
() =>
|
|
55
|
+
query("SELECT * FROM messages ORDER BY id DESC LIMIT 1")[0] ?? null
|
|
56
|
+
),
|
|
57
|
+
// AUTOINCREMENT keeps the highest id it ever gave in `sqlite_sequence`, so the last id
|
|
58
|
+
// outlives the message: one that expired or was deleted still counts.
|
|
59
|
+
last: Effect.sync(
|
|
60
|
+
() =>
|
|
61
|
+
decodeSequence(
|
|
62
|
+
storage.sql
|
|
63
|
+
.exec("SELECT seq FROM sqlite_sequence WHERE name = 'messages'")
|
|
64
|
+
.toArray()
|
|
65
|
+
)[0]?.seq ?? 0
|
|
66
|
+
),
|
|
67
|
+
read: (after) =>
|
|
68
|
+
Effect.sync(() =>
|
|
69
|
+
query("SELECT * FROM messages WHERE id > ? ORDER BY id", after)
|
|
70
|
+
),
|
|
71
|
+
post: Effect.fn("MessageStore.post")(function* (author, text) {
|
|
72
|
+
const at = yield* Clock.currentTimeMillis;
|
|
73
|
+
|
|
74
|
+
const message = decodeMessage(
|
|
75
|
+
storage.sql
|
|
76
|
+
.exec(
|
|
77
|
+
"INSERT INTO messages (author, text, at) VALUES (?, ?, ?) RETURNING *",
|
|
78
|
+
author,
|
|
79
|
+
text,
|
|
80
|
+
at
|
|
81
|
+
)
|
|
82
|
+
.one()
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
yield* alarmForOldest;
|
|
86
|
+
|
|
87
|
+
return message;
|
|
88
|
+
}),
|
|
89
|
+
remove: Effect.fn("MessageStore.remove")(function* (author, id) {
|
|
90
|
+
const [message] = query("SELECT * FROM messages WHERE id = ?", id);
|
|
91
|
+
|
|
92
|
+
if (message === undefined) {
|
|
93
|
+
return yield* new MessageNotFound({ id });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (message.author !== author) {
|
|
97
|
+
return yield* new NotAuthor({ id });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
storage.sql.exec("DELETE FROM messages WHERE id = ?", id);
|
|
101
|
+
}),
|
|
102
|
+
expire: Effect.gen(function* () {
|
|
103
|
+
const now = yield* Clock.currentTimeMillis;
|
|
104
|
+
|
|
105
|
+
storage.sql.exec("DELETE FROM messages WHERE at <= ?", now - day);
|
|
106
|
+
|
|
107
|
+
yield* alarmForOldest;
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
})
|
|
111
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
import { RoomName } from "./chat/Chat.js";
|
|
5
|
+
import { LobbyHost } from "./lobby/host.js";
|
|
6
|
+
import { layerLobbyHost } from "./platform/lobby-stubs.js";
|
|
7
|
+
|
|
8
|
+
const decodeRoom = Schema.decodeUnknownEffect(RoomName);
|
|
9
|
+
|
|
10
|
+
// The rooms' activity for another Worker, over a service binding to this one's `RoomsEntrypoint`,
|
|
11
|
+
// which worker.ts exports, as the lobby keeps it. A method of a `WorkerEntrypoint` is no Effect
|
|
12
|
+
// program, so each call runs as one, given the lobby's Layer: its client is built in the request
|
|
13
|
+
// that uses it and closed with it, which costs no I/O. Why a client kept across requests strands
|
|
14
|
+
// is on `ActionDurableObject.layerStubClient`.
|
|
15
|
+
export class RoomsEntrypoint extends WorkerEntrypoint {
|
|
16
|
+
// The calling Worker is another deployment, so its room is checked as any input is: an unknown
|
|
17
|
+
// room rejects the call with the schema's error, as a lobby that cannot be reached rejects it
|
|
18
|
+
// with the defect lobby-stubs.ts makes of it.
|
|
19
|
+
async latestAt(room: string) {
|
|
20
|
+
return await Effect.runPromise(
|
|
21
|
+
Effect.flatMap(decodeRoom(room), (name) =>
|
|
22
|
+
LobbyHost.use((lobby) =>
|
|
23
|
+
Effect.map(
|
|
24
|
+
lobby.previews,
|
|
25
|
+
(previews) =>
|
|
26
|
+
previews.find((preview) => preview.room === name)?.latestAt ??
|
|
27
|
+
null
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
).pipe(Effect.provide(layerLobbyHost))
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
2
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
3
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
4
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
5
|
+
import { env } from "cloudflare:workers";
|
|
6
|
+
import { Layer } from "effect";
|
|
7
|
+
import { RpcServer } from "effect/rpc";
|
|
8
|
+
|
|
9
|
+
import { LobbyHttp, RoomHttp } from "./Api.js";
|
|
10
|
+
import { messageEvents } from "./chat/events.js";
|
|
11
|
+
import { messages } from "./chat/handlers.js";
|
|
12
|
+
import { Login } from "./identity/Identity.js";
|
|
13
|
+
import { authenticate } from "./identity/verifier.js";
|
|
14
|
+
import { LobbyCalls } from "./lobby/Calls.js";
|
|
15
|
+
import { previewEvents } from "./lobby/events.js";
|
|
16
|
+
import { calls as lobbyHandlers, lobby } from "./lobby/handlers.js";
|
|
17
|
+
import {
|
|
18
|
+
LobbyRpc,
|
|
19
|
+
protocolVersion,
|
|
20
|
+
RoomRpc,
|
|
21
|
+
unsupportedProtocol,
|
|
22
|
+
} from "./Rpc.js";
|
|
23
|
+
|
|
24
|
+
const room = [messages, messageEvents];
|
|
25
|
+
|
|
26
|
+
const byRoom = (input: { readonly room: string }) => input.room;
|
|
27
|
+
|
|
28
|
+
// Where each room's calls and watches are served: in the room's object, the one each contract's
|
|
29
|
+
// key names. worker.ts gives it to the room's class, which then refuses an input naming another
|
|
30
|
+
// room, on its socket too; the Worker serves it below.
|
|
31
|
+
export const rooms = ActionDurableObject.placed(RoomHttp, {
|
|
32
|
+
readMessages: byRoom,
|
|
33
|
+
postMessage: byRoom,
|
|
34
|
+
deleteMessage: byRoom,
|
|
35
|
+
"message.posted": byRoom,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// One socket route for each object's pages, refusing a page of another version.
|
|
39
|
+
const pageSocket = ActionDurableObject.layerWebSocket({
|
|
40
|
+
path: "/rpc",
|
|
41
|
+
protocol: { version: protocolVersion, code: unsupportedProtocol },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// What a room's object serves. Its rpcs on WebSockets the runtime holds while the object
|
|
45
|
+
// hibernates: a socket keeps the token its upgrade request carried, and each message is
|
|
46
|
+
// authenticated with it, or with the message's own. The socket's route refuses a page naming
|
|
47
|
+
// another version in its URL, `?protocol=<n>` as browser.ts sends it, with a close code the page's
|
|
48
|
+
// client reads as `SocketCloseError` and stops connecting again at, not a 403, which a browser
|
|
49
|
+
// reports as a bare 1006 its page connects again after. Its JSON routes, where the Worker sends
|
|
50
|
+
// each call and watch on, the token with it: the object authenticates it again, and authorizes.
|
|
51
|
+
// It requires `MessageStore`, `Room` and `Activity` at startup: worker.ts chooses them.
|
|
52
|
+
export const roomRoutes = Layer.mergeAll(
|
|
53
|
+
ActionRpc.layer(RoomRpc, room).pipe(Layer.provide(pageSocket)),
|
|
54
|
+
ActionHttp.layer(RoomHttp, room)
|
|
55
|
+
).pipe(Layer.provide(authenticate));
|
|
56
|
+
|
|
57
|
+
// What the lobby's object serves: `PreviewsChanged` on its pages' sockets, a socket a page
|
|
58
|
+
// signed out opens too, as nothing on it is protected. It requires `PreviewStore` at startup.
|
|
59
|
+
export const lobbyRoutes = ActionRpc.layer(LobbyRpc, [previewEvents]).pipe(
|
|
60
|
+
Layer.provide(pageSocket)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// What the lobby's object serves over its stub: its own calls, on Effect's own RpcServer, for
|
|
64
|
+
// the rooms and the Worker, which call them with Cloudflare's own RPC. It requires `PreviewStore`
|
|
65
|
+
// at startup.
|
|
66
|
+
export const lobbyCalls = RpcServer.layer(LobbyCalls).pipe(
|
|
67
|
+
Layer.provide(lobbyHandlers)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// The rooms as the Worker serves them, each call sent on to its room's object. The room is read
|
|
71
|
+
// from `env` as each call is sent.
|
|
72
|
+
const inRooms = ActionDurableObject.reaching(rooms, () => env.CHAT_ROOM);
|
|
73
|
+
|
|
74
|
+
// What the Worker serves: the rooms' JSON routes and `previews`, which it asks the lobby's object
|
|
75
|
+
// for, at `/api`, and all of them at `/mcp`, the actions as tools and `MessagePosted` as an MCP
|
|
76
|
+
// event, so one agent's connection reaches every room. Nothing in a Worker outlives a request, so
|
|
77
|
+
// the endpoint is request scoped: MCP 2026-07-28 alone, and events by `events/poll`, a poll every
|
|
78
|
+
// 30 seconds as events.ts asks. The Worker authenticates each call, and the room's object again.
|
|
79
|
+
// It requires `LobbyHost`.
|
|
80
|
+
export const workerRoutes = Layer.mergeAll(
|
|
81
|
+
ActionHttp.layer(LobbyHttp, lobby),
|
|
82
|
+
ActionHttp.layer(RoomHttp, inRooms),
|
|
83
|
+
ActionMcp.layerHttp([lobby, ...inRooms], {
|
|
84
|
+
name: "chat-rooms",
|
|
85
|
+
version: "1.0.0",
|
|
86
|
+
authentication: Login,
|
|
87
|
+
requestScoped: true,
|
|
88
|
+
})
|
|
89
|
+
).pipe(Layer.provide(authenticate));
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
2
|
+
import { Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { HttpRouter, HttpServer } from "effect/http";
|
|
4
|
+
|
|
5
|
+
import { Activity } from "./chat/activity.js";
|
|
6
|
+
import { RoomName } from "./chat/Chat.js";
|
|
7
|
+
import { Room } from "./chat/room.js";
|
|
8
|
+
import { MessageStore } from "./chat/store.js";
|
|
9
|
+
import { layerKvPreviews } from "./platform/kv-previews.js";
|
|
10
|
+
import {
|
|
11
|
+
layerActivity,
|
|
12
|
+
layerLobbyHost,
|
|
13
|
+
tellLobby,
|
|
14
|
+
} from "./platform/lobby-stubs.js";
|
|
15
|
+
import { layerSql } from "./platform/sql-store.js";
|
|
16
|
+
import {
|
|
17
|
+
lobbyCalls,
|
|
18
|
+
lobbyRoutes,
|
|
19
|
+
roomRoutes,
|
|
20
|
+
rooms,
|
|
21
|
+
workerRoutes,
|
|
22
|
+
} from "./routes.js";
|
|
23
|
+
|
|
24
|
+
// The rooms for another Worker, over a service binding: a named entrypoint of this one.
|
|
25
|
+
export { RoomsEntrypoint } from "./rooms-entrypoint.js";
|
|
26
|
+
|
|
27
|
+
// Entrypoint, Wrangler's `main`: the composition root of the Worker and of every object. It
|
|
28
|
+
// chooses the stores, the room and the stubs, and routes each request.
|
|
29
|
+
|
|
30
|
+
const decodeRoom = Schema.decodeUnknownEffect(RoomName);
|
|
31
|
+
|
|
32
|
+
// A room's Durable Object, bound as CHAT_ROOM in wrangler.jsonc under this export's name. Each
|
|
33
|
+
// instance builds its services, the store, the room and its activity, then its routes, as it is
|
|
34
|
+
// constructed, again after every eviction. The room is the name the Worker reached the object by,
|
|
35
|
+
// `getByName(room)`, decoded once there: `ActionDurableObject.name` dies for an object reached by
|
|
36
|
+
// an id, and the build fails for a name `RoomName` does not list, each a bug in the Worker. Given
|
|
37
|
+
// `rooms`, it refuses a call or a watch whose input names another room. Its `lobby` task tells
|
|
38
|
+
// the lobby of each change, on the alarm until the lobby hears. The alarm expires old messages
|
|
39
|
+
// and tells the lobby, as an expired message can be the room's latest.
|
|
40
|
+
export class ChatRoom extends ActionDurableObject.make({
|
|
41
|
+
placed: rooms,
|
|
42
|
+
services: () =>
|
|
43
|
+
Layer.mergeAll(layerSql, layerActivity).pipe(
|
|
44
|
+
Layer.provideMerge(
|
|
45
|
+
Layer.effect(Room)(Effect.flatMap(ActionDurableObject.name, decodeRoom))
|
|
46
|
+
)
|
|
47
|
+
),
|
|
48
|
+
routes: roomRoutes,
|
|
49
|
+
tasks: { lobby: tellLobby },
|
|
50
|
+
alarm: () =>
|
|
51
|
+
Activity.use((activity) =>
|
|
52
|
+
activity.changing(MessageStore.use((store) => store.expire))
|
|
53
|
+
),
|
|
54
|
+
}) {}
|
|
55
|
+
|
|
56
|
+
// The lobby's Durable Object, bound as CHAT_LOBBY: one for the Worker, named `lobby`, keeping
|
|
57
|
+
// every room's preview, which each room tells it of and its pages follow.
|
|
58
|
+
export class ChatLobby extends ActionDurableObject.make({
|
|
59
|
+
services: () => layerKvPreviews,
|
|
60
|
+
routes: lobbyRoutes,
|
|
61
|
+
calls: lobbyCalls,
|
|
62
|
+
}) {}
|
|
63
|
+
|
|
64
|
+
// The Worker's own handler, built once, when the module loads, which keeps the lobby's client
|
|
65
|
+
// from stranding as `ActionDurableObject.layerStubClient`'s gotchas say: building it does no I/O,
|
|
66
|
+
// which a Worker refuses outside a request. `toWebHandler` reads no `MaxBodySize`, so the
|
|
67
|
+
// platform limits request bodies.
|
|
68
|
+
const served = HttpRouter.toWebHandler(
|
|
69
|
+
workerRoutes.pipe(
|
|
70
|
+
Layer.provide(layerLobbyHost),
|
|
71
|
+
Layer.provide(HttpServer.layerServices)
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const isRoomName = Schema.is(RoomName);
|
|
76
|
+
|
|
77
|
+
// A page's socket, `/rooms/<room>/rpc` or `/lobby/rpc`, is its object's `/rpc`: forwarding the
|
|
78
|
+
// request forwards its upgrade, and the object answers it. Everything else is the Worker's own,
|
|
79
|
+
// the rooms' calls among them, which it sends on to each room's object itself.
|
|
80
|
+
export default {
|
|
81
|
+
async fetch(request: Request, env: Cloudflare.Env) {
|
|
82
|
+
const url = new URL(request.url);
|
|
83
|
+
|
|
84
|
+
const room =
|
|
85
|
+
/^\/rooms\/(?<room>[^/]+)\/rpc$/u.exec(url.pathname)?.groups?.room ?? "";
|
|
86
|
+
|
|
87
|
+
if (url.pathname === "/lobby/rpc" || isRoomName(room)) {
|
|
88
|
+
url.pathname = "/rpc";
|
|
89
|
+
|
|
90
|
+
const object = isRoomName(room)
|
|
91
|
+
? env.CHAT_ROOM.getByName(room)
|
|
92
|
+
: env.CHAT_LOBBY.getByName("lobby");
|
|
93
|
+
|
|
94
|
+
return await object.fetch(new Request(url, request));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return await served.handler(request);
|
|
98
|
+
},
|
|
99
|
+
} satisfies ExportedHandler<Cloudflare.Env>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
3
|
+
import { runInDurableObject } from "cloudflare:test";
|
|
4
|
+
import { env } from "cloudflare:workers";
|
|
5
|
+
import { Clock, Effect, Layer } from "effect";
|
|
6
|
+
|
|
7
|
+
import { MessageNotFound, NotAuthor } from "../../src/chat/Chat.js";
|
|
8
|
+
import { MessageStore } from "../../src/chat/store.js";
|
|
9
|
+
import { layerSql } from "../../src/platform/sql-store.js";
|
|
10
|
+
|
|
11
|
+
const day = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
let objects = 0;
|
|
14
|
+
|
|
15
|
+
// The adapter against a room's real SQLite database, inside a fresh object of its own: what it
|
|
16
|
+
// reaches is the Workers runtime, so the runtime is its stand-in.
|
|
17
|
+
const inObject = <A, E>(
|
|
18
|
+
use: (
|
|
19
|
+
store: MessageStore["Service"],
|
|
20
|
+
state: DurableObjectState
|
|
21
|
+
) => Effect.Effect<A, E>
|
|
22
|
+
) =>
|
|
23
|
+
Effect.promise(async () => {
|
|
24
|
+
objects += 1;
|
|
25
|
+
|
|
26
|
+
return await runInDurableObject(
|
|
27
|
+
env.CHAT_ROOM.getByName(`sql-store-${objects}`),
|
|
28
|
+
async (_, state) =>
|
|
29
|
+
await Effect.runPromise(
|
|
30
|
+
MessageStore.use((store) => use(store, state)).pipe(
|
|
31
|
+
Effect.provide(
|
|
32
|
+
layerSql.pipe(
|
|
33
|
+
Layer.provide(ActionDurableObject.Alarm.layer),
|
|
34
|
+
Layer.provide(Layer.succeed(ActionDurableObject.State, state))
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// A clock a millisecond later at each read, which never yields: posts running at once read
|
|
43
|
+
// times of their own, where at one shared time the oldest message and the latest would set the
|
|
44
|
+
// same alarm. Effect's TestClock moves on through a semaphore, which lets each post finish first.
|
|
45
|
+
const tickingFrom = (start: number): Clock.Clock => {
|
|
46
|
+
let now = start;
|
|
47
|
+
const tick = () => (now += 1);
|
|
48
|
+
const nanos = () => BigInt(now) * 1_000_000n;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
currentTimeMillisUnsafe: tick,
|
|
52
|
+
currentTimeMillis: Effect.sync(tick),
|
|
53
|
+
currentTimeNanosUnsafe: nanos,
|
|
54
|
+
currentTimeNanos: Effect.sync(nanos),
|
|
55
|
+
monotonicTimeNanosUnsafe: nanos,
|
|
56
|
+
monotonicTimeNanos: Effect.sync(nanos),
|
|
57
|
+
sleep: () => Effect.void,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("the SQL message store", () => {
|
|
62
|
+
it.live("numbers a room's messages and reads those after an id", () =>
|
|
63
|
+
inObject((store) =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const first = yield* store.post("ada", "One.");
|
|
66
|
+
const second = yield* store.post("grace", "Two.");
|
|
67
|
+
|
|
68
|
+
expect(second.id).toBe(first.id + 1);
|
|
69
|
+
expect(yield* store.read(0)).toStrictEqual([first, second]);
|
|
70
|
+
expect(yield* store.read(first.id)).toStrictEqual([second]);
|
|
71
|
+
expect(yield* store.latest).toStrictEqual(second);
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it.live("deletes only its author's message", () =>
|
|
77
|
+
inObject((store) =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
const posted = yield* store.post("ada", "Mine.");
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
yield* Effect.flip(store.remove("grace", posted.id))
|
|
83
|
+
).toStrictEqual(new NotAuthor({ id: posted.id }));
|
|
84
|
+
expect(
|
|
85
|
+
yield* Effect.flip(store.remove("ada", posted.id + 1))
|
|
86
|
+
).toStrictEqual(new MessageNotFound({ id: posted.id + 1 }));
|
|
87
|
+
|
|
88
|
+
yield* store.remove("ada", posted.id);
|
|
89
|
+
|
|
90
|
+
expect(yield* store.latest).toBeNull();
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it.live(
|
|
96
|
+
"sets the alarm for the oldest message's expiry when messages are posted at once",
|
|
97
|
+
() =>
|
|
98
|
+
inObject((store, state) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const posted = yield* Effect.forEach(
|
|
101
|
+
Array.from({ length: 32 }, (_, i) => `Message ${i}.`),
|
|
102
|
+
(text) => store.post("ada", text),
|
|
103
|
+
{ concurrency: "unbounded" }
|
|
104
|
+
).pipe(Effect.provideService(Clock.Clock, tickingFrom(Date.now())));
|
|
105
|
+
|
|
106
|
+
expect(new Set(posted.map(({ at }) => at)).size).toBe(32);
|
|
107
|
+
expect(
|
|
108
|
+
yield* Effect.promise(async () => await state.storage.getAlarm())
|
|
109
|
+
).toBe(Math.min(...posted.map(({ at }) => at)) + day);
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
it.live(
|
|
115
|
+
"sets the alarm for the first message's expiry and leaves it for later ones",
|
|
116
|
+
() =>
|
|
117
|
+
inObject((store, state) =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
const first = yield* store.post("ada", "One.");
|
|
120
|
+
|
|
121
|
+
yield* Effect.sleep("1 millis");
|
|
122
|
+
|
|
123
|
+
const second = yield* store.post("ada", "Two.");
|
|
124
|
+
|
|
125
|
+
expect(second.at).toBeGreaterThan(first.at);
|
|
126
|
+
expect(
|
|
127
|
+
yield* Effect.promise(async () => await state.storage.getAlarm())
|
|
128
|
+
).toBe(first.at + day);
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
2
|
+
import { exports } from "cloudflare:workers";
|
|
3
|
+
import { Effect, Layer } from "effect";
|
|
4
|
+
import { RpcClient, RpcSerialization } from "effect/rpc";
|
|
5
|
+
import { Socket } from "effect/socket";
|
|
6
|
+
|
|
7
|
+
import { protocolVersion, unsupportedProtocol } from "../../src/Rpc.js";
|
|
8
|
+
|
|
9
|
+
// A page's socket to an object, `<at>/rpc` upgraded through the Worker as a browser's would be,
|
|
10
|
+
// naming the version of the rpcs as `connection(url)` does. A test has no network to open one by
|
|
11
|
+
// its URL, so it hands the upgraded socket to Effect's client protocol, the protocol
|
|
12
|
+
// `connection(url)` builds over a URL, its reconnection included, and keeps the socket, which a
|
|
13
|
+
// test listens on for what the object sent. The socket is accepted as Effect's client listens to
|
|
14
|
+
// it, as a browser's socket is constructed with its listeners: accepted before, a close the
|
|
15
|
+
// object sent at once could go unheard. `connects` counts the client's attempts: each after the
|
|
16
|
+
// first would be another upgrade in a browser.
|
|
17
|
+
export const pageSocket = (
|
|
18
|
+
at: string,
|
|
19
|
+
headers: Record<string, string> = {},
|
|
20
|
+
version = protocolVersion
|
|
21
|
+
) =>
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const response = yield* Effect.promise(
|
|
24
|
+
async () =>
|
|
25
|
+
await exports.default.fetch(
|
|
26
|
+
`https://chat.example${at}/rpc?protocol=${version}`,
|
|
27
|
+
{
|
|
28
|
+
headers: { ...headers, upgrade: "websocket" },
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const socket = response.webSocket;
|
|
34
|
+
|
|
35
|
+
if (socket === null) {
|
|
36
|
+
return yield* Effect.die(new Error(`No WebSocket: ${response.status}`));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let connects = 0;
|
|
40
|
+
|
|
41
|
+
const accepted = Effect.sync(() => {
|
|
42
|
+
if (connects === 0) {
|
|
43
|
+
socket.accept();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
connects += 1;
|
|
47
|
+
|
|
48
|
+
return socket;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const protocol = yield* Layer.build(
|
|
52
|
+
RpcClient.layerProtocolSocket({
|
|
53
|
+
retryPolicy: ActionRpc.reconnectUntil(unsupportedProtocol),
|
|
54
|
+
}).pipe(
|
|
55
|
+
Layer.provide(
|
|
56
|
+
Layer.effect(Socket.Socket)(Socket.fromWebSocket(accepted))
|
|
57
|
+
),
|
|
58
|
+
Layer.provide(RpcSerialization.layerJson)
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return { socket, protocol, connects: () => connects };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A page's socket to a room.
|
|
66
|
+
export const roomSocket = (
|
|
67
|
+
room: string,
|
|
68
|
+
headers: Record<string, string> = {},
|
|
69
|
+
version = protocolVersion
|
|
70
|
+
) => pageSocket(`/rooms/${room}`, headers, version);
|