@gjermundgaraba/effect-actions-cloudflare 0.0.0-stage → 0.22.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 +42 -0
- package/dist/alarm.mjs +155 -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/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 +110 -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 +579 -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,107 @@
|
|
|
1
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
2
|
+
import { Effect, Layer, Predicate, Stream } from "effect";
|
|
3
|
+
import { Headers } from "effect/http";
|
|
4
|
+
import { RpcClient, RpcSerialization } from "effect/rpc";
|
|
5
|
+
import { Socket } from "effect/socket";
|
|
6
|
+
|
|
7
|
+
import { MessagePosted } from "./chat/Chat.js";
|
|
8
|
+
import type { RoomName } from "./chat/Chat.js";
|
|
9
|
+
import { PreviewsChanged } from "./lobby/Lobby.js";
|
|
10
|
+
import {
|
|
11
|
+
LobbyRpc,
|
|
12
|
+
protocolVersion,
|
|
13
|
+
RoomRpc,
|
|
14
|
+
unsupportedProtocol,
|
|
15
|
+
} from "./Rpc.js";
|
|
16
|
+
|
|
17
|
+
// Client module: what a web page imports. It holds the binding and nothing of the server.
|
|
18
|
+
|
|
19
|
+
// Effect's own client protocol, one WebSocket to an object, speaking JSON: the socket's URL is
|
|
20
|
+
// `wss://<host>/rooms/<room>/rpc` for a room and `wss://<host>/lobby/rpc` for the lobby, given the version of the rpcs the page was built
|
|
21
|
+
// with. An object serving another closes the socket at once with `unsupportedProtocol`, which
|
|
22
|
+
// fails every call and stream with a `SocketCloseError` of that code; `reconnectUntil` stops
|
|
23
|
+
// the client there, which Effect's own policy would not, connecting again every five seconds,
|
|
24
|
+
// each attempt waking the object: show "reload". The layer owns the socket, so provide it
|
|
25
|
+
// once, around the page's whole program; a page provides `Socket.layerWebSocketConstructorGlobal`, the browser's own
|
|
26
|
+
// WebSocket. The client pings every five seconds and the object's runtime answers, so a page
|
|
27
|
+
// left open does not wake the object.
|
|
28
|
+
export const connection = (url: string) =>
|
|
29
|
+
RpcClient.layerProtocolSocket({
|
|
30
|
+
retryPolicy: ActionRpc.reconnectUntil(unsupportedProtocol),
|
|
31
|
+
}).pipe(
|
|
32
|
+
Layer.provide(Socket.layerWebSocket(`${url}?protocol=${protocolVersion}`)),
|
|
33
|
+
Layer.provide(RpcSerialization.layerJson)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// A room as a page uses it, over the socket to that room's object: each call names the room, as
|
|
37
|
+
// every call of a room does, and the object refuses one naming another. A browser WebSocket sets
|
|
38
|
+
// no header when it connects, so the token travels on each message; a client that can set headers
|
|
39
|
+
// on the upgrade, such as a Worker or a native app, sends it once there, and the socket keeps it.
|
|
40
|
+
export const openRoom = (room: RoomName, token: string) =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const client = yield* ActionRpc.client(RoomRpc);
|
|
43
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
44
|
+
const signedIn = RpcClient.withHeaders(headers);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
// The room's messages after `after`, the id of the last one the page holds: 0 reads them
|
|
48
|
+
// all.
|
|
49
|
+
read: (after: number) =>
|
|
50
|
+
client.readMessages({ room, after }).pipe(
|
|
51
|
+
signedIn,
|
|
52
|
+
Effect.map(({ messages }) => messages)
|
|
53
|
+
),
|
|
54
|
+
// A post the socket lost, as the room's object restarted, fails with an `RpcClientError`,
|
|
55
|
+
// whether or not it reached the room: sent again with its `request`, made once for the
|
|
56
|
+
// message, it is posted once.
|
|
57
|
+
post: (text: string) =>
|
|
58
|
+
Effect.flatMap(
|
|
59
|
+
Effect.sync(() => crypto.randomUUID()),
|
|
60
|
+
(request) =>
|
|
61
|
+
client.postMessage({ room, text, request }).pipe(
|
|
62
|
+
signedIn,
|
|
63
|
+
Effect.retry({
|
|
64
|
+
while: (error) => Predicate.isTagged(error, "RpcClientError"),
|
|
65
|
+
times: 2,
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
),
|
|
69
|
+
// Each message posted to the room after `after`, the id of the last one the page holds: a
|
|
70
|
+
// page opening the room reads it, then watches from the last id it read, so a post between
|
|
71
|
+
// the two is not lost. The room's object keeps the watch while it hibernates, and resumes it
|
|
72
|
+
// when it next wakes, authenticating its token again: a token that expired meanwhile ends
|
|
73
|
+
// the watch with `Unauthenticated`, so watch again with a fresh one. `watch` connects again
|
|
74
|
+
// after a drop and resumes from the last message it delivered; a `Gap` says some were
|
|
75
|
+
// missed, which the page reads with `read`. The headers go with the stream, as `signedIn`
|
|
76
|
+
// puts them on a call, and so with every request it sends again.
|
|
77
|
+
messages: (after: number) =>
|
|
78
|
+
client
|
|
79
|
+
.watch(MessagePosted, { room }, { cursor: String(after) })
|
|
80
|
+
.pipe(
|
|
81
|
+
Stream.updateService(
|
|
82
|
+
RpcClient.CurrentHeaders,
|
|
83
|
+
Headers.merge(Headers.fromInput(headers))
|
|
84
|
+
)
|
|
85
|
+
),
|
|
86
|
+
remove: (id: number) =>
|
|
87
|
+
client.deleteMessage({ room, id }).pipe(
|
|
88
|
+
signedIn,
|
|
89
|
+
Effect.as("removed" as const),
|
|
90
|
+
Effect.catchTags({
|
|
91
|
+
MessageNotFound: () => Effect.succeed("removed" as const),
|
|
92
|
+
NotAuthor: () => Effect.succeed("not-yours" as const),
|
|
93
|
+
})
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The lobby as a page follows it, signed in or not: every room's preview, first as it is, then
|
|
99
|
+
// whole again at each change. A watch given no cursor starts with the previews, so the page reads
|
|
100
|
+
// nothing first; after a drop it resumes from the last it received, and hears the previews again
|
|
101
|
+
// only if they changed meanwhile. A `Gap` needs no read either: the next occurrence is whole.
|
|
102
|
+
export const followLobby = Effect.map(ActionRpc.client(LobbyRpc), (client) =>
|
|
103
|
+
client.watch(PreviewsChanged, {}).pipe(
|
|
104
|
+
Stream.filter((received) => Predicate.isTagged(received, "Occurrence")),
|
|
105
|
+
Stream.map(({ data }) => data.previews)
|
|
106
|
+
)
|
|
107
|
+
);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
6
|
+
|
|
7
|
+
// DEMO ONLY: the rooms, fixed. Each name is one Durable Object, which the Worker reaches only
|
|
8
|
+
// for a name listed here, so no caller creates an object of its own. A real application keeps
|
|
9
|
+
// its rooms in a store and checks a name there before it reaches `getByName`.
|
|
10
|
+
export const RoomName = Schema.Literals(["general", "design"]);
|
|
11
|
+
|
|
12
|
+
export type RoomName = typeof RoomName.Type;
|
|
13
|
+
|
|
14
|
+
// A message's `id` counts up within its room, so a client asks for what came after the last
|
|
15
|
+
// one it holds. `at` is when it was posted, in milliseconds: messages expire a day later.
|
|
16
|
+
export const Message = Schema.Struct({
|
|
17
|
+
id: Schema.Int,
|
|
18
|
+
author: Schema.String,
|
|
19
|
+
text: Schema.String,
|
|
20
|
+
at: Schema.Int,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type Message = typeof Message.Type;
|
|
24
|
+
|
|
25
|
+
// A declared error is part of the contract: every client decodes it as this class. Its status is
|
|
26
|
+
// for the JSON routes; an rpc carries none.
|
|
27
|
+
export class MessageNotFound extends Schema.TaggedError<
|
|
28
|
+
MessageNotFound,
|
|
29
|
+
{ readonly brand: unique symbol }
|
|
30
|
+
>()("MessageNotFound", { id: Schema.Int }, { httpApiStatus: 404 }) {}
|
|
31
|
+
|
|
32
|
+
export class NotAuthor extends Schema.TaggedError<
|
|
33
|
+
NotAuthor,
|
|
34
|
+
{ readonly brand: unique symbol }
|
|
35
|
+
>()("NotAuthor", { id: Schema.Int }, { httpApiStatus: 403 }) {}
|
|
36
|
+
|
|
37
|
+
// Every call and watch of a room names it in its input: the Worker sends each on to the room's
|
|
38
|
+
// object, and the object refuses an input naming another room, so a page's socket to one room
|
|
39
|
+
// cannot reach another. Its schema is the room names, so an unknown room is `InvalidInput`
|
|
40
|
+
// before any object is reached.
|
|
41
|
+
const inRoom = { room: RoomName };
|
|
42
|
+
|
|
43
|
+
const signedIn = { caller: CurrentMember } as const;
|
|
44
|
+
|
|
45
|
+
// Reads once: a page opening a room reads what is there, then watches `MessagePosted` below from
|
|
46
|
+
// the last id it read.
|
|
47
|
+
export const ReadMessages = Action.make("readMessages", {
|
|
48
|
+
...signedIn,
|
|
49
|
+
description: "Read the room's messages after the given id; 0 reads them all.",
|
|
50
|
+
input: { ...inRoom, after: Schema.Int },
|
|
51
|
+
success: { messages: Schema.Array(Message) },
|
|
52
|
+
readOnly: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// `request` is the post's idempotency key: a page makes one at random for each message and
|
|
56
|
+
// sends it again when it retries the post, and the room answers the repeat as it answered the
|
|
57
|
+
// first, posting once. A model reads its description beside the tool, so it does the same.
|
|
58
|
+
export const PostMessage = Action.make("postMessage", {
|
|
59
|
+
...signedIn,
|
|
60
|
+
description: "Post a message to the room. It expires a day later.",
|
|
61
|
+
input: {
|
|
62
|
+
...inRoom,
|
|
63
|
+
text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)),
|
|
64
|
+
request: Schema.optional(
|
|
65
|
+
Schema.String.annotate({
|
|
66
|
+
description:
|
|
67
|
+
"A random id for this message, sent again on a retry: it is posted once.",
|
|
68
|
+
}).check(Schema.isMaxLength(64))
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
success: Message,
|
|
72
|
+
readOnly: false,
|
|
73
|
+
idempotencyKey: "request",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const DeleteMessage = Action.make("deleteMessage", {
|
|
77
|
+
...signedIn,
|
|
78
|
+
description: "Delete one of your own messages from the room.",
|
|
79
|
+
input: { ...inRoom, id: Schema.Int },
|
|
80
|
+
error: [MessageNotFound, NotAuthor],
|
|
81
|
+
readOnly: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const Messages = [ReadMessages, PostMessage, DeleteMessage] as const;
|
|
85
|
+
|
|
86
|
+
// Each message posted to the room, as it is posted: a page watches it on the socket it keeps
|
|
87
|
+
// open to the room's object. Signed-in, so each watch authenticates and runs `authorize` as it
|
|
88
|
+
// starts, and again each time the object wakes after hibernating, refused for a member outside
|
|
89
|
+
// the room, or one whose token has expired meanwhile.
|
|
90
|
+
export const MessagePosted = Event.make("message.posted", {
|
|
91
|
+
description: "A message posted to the room.",
|
|
92
|
+
input: inRoom,
|
|
93
|
+
payload: Message,
|
|
94
|
+
caller: CurrentMember,
|
|
95
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a room says its latest activity may have changed: the lobby's object, which worker.ts
|
|
6
|
+
* chooses. `changing` runs a change, then tells, answering as the change did: a post stands
|
|
7
|
+
* whether the lobby hears of it or not.
|
|
8
|
+
*/
|
|
9
|
+
export class Activity extends Context.Service<
|
|
10
|
+
Activity,
|
|
11
|
+
{
|
|
12
|
+
readonly changing: <A, E, R>(
|
|
13
|
+
change: Effect.Effect<A, E, R>
|
|
14
|
+
) => Effect.Effect<A, E, R>;
|
|
15
|
+
}
|
|
16
|
+
>()("chat-rooms/Activity") {}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
5
|
+
import { Room } from "./room.js";
|
|
6
|
+
|
|
7
|
+
// The one rule: a member reads and writes only the rooms they belong to. It is built as
|
|
8
|
+
// handlers are: this Effect runs once per instance and gets the room, a startup service; the
|
|
9
|
+
// function it returns runs before every handler and reads the caller of that call.
|
|
10
|
+
export const authorize = Effect.gen(function* () {
|
|
11
|
+
const room = yield* Room;
|
|
12
|
+
|
|
13
|
+
return Effect.fn("authorize")(function* () {
|
|
14
|
+
const member = yield* CurrentMember;
|
|
15
|
+
|
|
16
|
+
if (!member.rooms.includes(room)) {
|
|
17
|
+
return yield* new Action.Forbidden({
|
|
18
|
+
message: `Not a member of ${room}.`,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
2
|
+
import { Effect, Option, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
import { authorize } from "./authorize.js";
|
|
5
|
+
import { MessagePosted } from "./Chat.js";
|
|
6
|
+
import { MessageStore } from "./store.js";
|
|
7
|
+
|
|
8
|
+
// A cursor is the id of the last message a page holds, as `readMessages`' `after` is.
|
|
9
|
+
const idOf = Schema.decodeUnknownOption(
|
|
10
|
+
Schema.NumberFromString.pipe(Schema.check(Schema.isInt()))
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
// An agent polling over MCP asks again after `nextPoll`, 5 seconds by default: a request that
|
|
14
|
+
// often keeps the object from the 10 idle seconds after which the runtime may hibernate it, so it
|
|
15
|
+
// asks every 30 seconds, and an agent that needs a message sooner holds a stream open instead.
|
|
16
|
+
const nextPoll = "30 seconds";
|
|
17
|
+
|
|
18
|
+
// The room's messages as an event, read from the store that keeps them: a check source.
|
|
19
|
+
// `postMessage` notifies after each post, so a page receives it at once: when an object reads a
|
|
20
|
+
// check source, and what a write that does not notify misses, is on `ActionDurableObject.handlers`.
|
|
21
|
+
export const messageEvents = Event.implement(
|
|
22
|
+
MessagePosted,
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const store = yield* MessageStore;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
check: (_input, cursor) =>
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const last = yield* store.last;
|
|
30
|
+
|
|
31
|
+
const after = Option.filter(
|
|
32
|
+
idOf(cursor),
|
|
33
|
+
(id) => id >= 0 && id <= last
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// No cursor starts after the room's last message. One this room never gave, such as
|
|
37
|
+
// an id past its last from another copy of its database, is answered `truncated` from
|
|
38
|
+
// there: the page learns it missed messages, and reads them with `readMessages`. A
|
|
39
|
+
// message deleted or expired after a cursor is simply not read: the store keeps no
|
|
40
|
+
// record of either, so no gap marks it.
|
|
41
|
+
if (Option.isNone(after)) {
|
|
42
|
+
return {
|
|
43
|
+
cursor: String(last),
|
|
44
|
+
events: [],
|
|
45
|
+
truncated: cursor !== null,
|
|
46
|
+
nextPoll,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const messages = yield* store.read(after.value);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
cursor: String(messages.at(-1)?.id ?? after.value),
|
|
54
|
+
events: messages.map((data) => ({
|
|
55
|
+
eventId: String(data.id),
|
|
56
|
+
data,
|
|
57
|
+
})),
|
|
58
|
+
nextPoll,
|
|
59
|
+
};
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}),
|
|
63
|
+
{ authorize }
|
|
64
|
+
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
import { CurrentMember } from "../identity/Identity.js";
|
|
6
|
+
import { Activity } from "./activity.js";
|
|
7
|
+
import { authorize } from "./authorize.js";
|
|
8
|
+
import { MessagePosted, Messages } from "./Chat.js";
|
|
9
|
+
import { MessageStore } from "./store.js";
|
|
10
|
+
|
|
11
|
+
// The builder runs once per instance, when the object is constructed, and again on the
|
|
12
|
+
// instance the runtime constructs after evicting one: it is where a handler gets its startup
|
|
13
|
+
// services. The caller is read per call, because every message has its own.
|
|
14
|
+
export const messages = Action.implement(
|
|
15
|
+
Messages,
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const store = yield* MessageStore;
|
|
18
|
+
const events = yield* Event.Events;
|
|
19
|
+
const activity = yield* Activity;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
readMessages: ({ after }) =>
|
|
23
|
+
Effect.map(store.read(after), (read) => ({ messages: read })),
|
|
24
|
+
// The message is in the store, which `MessagePosted`'s check reads: notifying wakes the
|
|
25
|
+
// pages watching the room to read it now. The lobby is another object, whose event this
|
|
26
|
+
// one cannot notify: the room tells it over its stub instead, and the lobby notifies.
|
|
27
|
+
postMessage: ({ text }) =>
|
|
28
|
+
Effect.flatMap(CurrentMember, (member) =>
|
|
29
|
+
store.post(member.id, text)
|
|
30
|
+
).pipe(Effect.tap(events.notify(MessagePosted)), activity.changing),
|
|
31
|
+
deleteMessage: ({ id }) =>
|
|
32
|
+
activity.changing(
|
|
33
|
+
Effect.flatMap(CurrentMember, (member) => store.remove(member.id, id))
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
}),
|
|
37
|
+
{ authorize }
|
|
38
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
import type { RoomName } from "./Chat.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The room an instance holds. Each room is one Durable Object, reached by its name, so this is
|
|
7
|
+
* a startup service: one value for the instance's whole life, unlike the caller, which every
|
|
8
|
+
* message names again.
|
|
9
|
+
*/
|
|
10
|
+
export class Room extends Context.Service<Room, RoomName>()(
|
|
11
|
+
"chat-rooms/Room"
|
|
12
|
+
) {}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { Message, MessageNotFound, NotAuthor } from "./Chat.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The room's messages. That only its author deletes a message is checked here, in the data
|
|
8
|
+
* access, so no surface and no handler can skip it. A message expires a day after it was
|
|
9
|
+
* posted: `expire` deletes those, and the store sees that it runs.
|
|
10
|
+
*/
|
|
11
|
+
export class MessageStore extends Context.Service<
|
|
12
|
+
MessageStore,
|
|
13
|
+
{
|
|
14
|
+
readonly latest: Effect.Effect<Message | null>;
|
|
15
|
+
/** The id of the last message ever posted, kept or not, or 0 before the first. */
|
|
16
|
+
readonly last: Effect.Effect<number>;
|
|
17
|
+
readonly read: (after: number) => Effect.Effect<readonly Message[]>;
|
|
18
|
+
readonly post: (author: string, text: string) => Effect.Effect<Message>;
|
|
19
|
+
readonly remove: (
|
|
20
|
+
author: string,
|
|
21
|
+
id: number
|
|
22
|
+
) => Effect.Effect<void, MessageNotFound | NotAuthor>;
|
|
23
|
+
readonly expire: Effect.Effect<void>;
|
|
24
|
+
}
|
|
25
|
+
>()("chat-rooms/MessageStore") {}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The Worker's bindings and its main module, as `wrangler types` writes them from
|
|
2
|
+
// wrangler.jsonc: regenerate them with it when the configuration changes.
|
|
3
|
+
declare namespace Cloudflare {
|
|
4
|
+
interface Env {
|
|
5
|
+
readonly CHAT_ROOM: DurableObjectNamespace<import("./worker.js").ChatRoom>;
|
|
6
|
+
readonly CHAT_LOBBY: DurableObjectNamespace<import("./worker.js").ChatLobby>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface GlobalProps {
|
|
10
|
+
mainModule: typeof import("./worker.js");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
2
|
+
import { Context, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
export const Member = Schema.Struct({
|
|
5
|
+
id: Schema.String,
|
|
6
|
+
rooms: Schema.Array(Schema.String),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type Member = typeof Member.Type;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Who sent a message. A protected action declares it, `caller: CurrentMember`; authentication
|
|
13
|
+
* provides it for each message, from the token the message carries or the one its socket kept.
|
|
14
|
+
*/
|
|
15
|
+
export class CurrentMember extends Context.Service<CurrentMember, Member>()(
|
|
16
|
+
"chat-rooms/CurrentMember"
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
// How a remote caller proves it is CurrentMember: a bearer token, the default scheme. Clients
|
|
20
|
+
// import this descriptor with the bindings; its verifier, in verifier.ts, stays on the server.
|
|
21
|
+
export const Login = Authentication.make("chat-rooms.Login", CurrentMember);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Member } from "./Identity.js";
|
|
2
|
+
|
|
3
|
+
// DEMO ONLY: fixed members, each token their name. A real application keeps members in its
|
|
4
|
+
// identity provider and their rooms in a store.
|
|
5
|
+
export const members = {
|
|
6
|
+
ada: { id: "ada", rooms: ["general", "design"] },
|
|
7
|
+
grace: { id: "grace", rooms: ["general"] },
|
|
8
|
+
} satisfies Record<string, Member>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
3
|
+
import { Effect, Redacted } from "effect";
|
|
4
|
+
|
|
5
|
+
import { Login } from "./Identity.js";
|
|
6
|
+
import { members } from "./members.js";
|
|
7
|
+
|
|
8
|
+
const isToken = (token: string): token is keyof typeof members =>
|
|
9
|
+
Object.hasOwn(members, token);
|
|
10
|
+
|
|
11
|
+
// DEMO ONLY: a token is a member's name. A real verifier checks a signed token with your
|
|
12
|
+
// authorization server's library, its audience and expiry included: a socket keeps the token
|
|
13
|
+
// its page connected with, and this runs on every message, so an expired one is refused.
|
|
14
|
+
const verify = (token: Redacted.Redacted) => {
|
|
15
|
+
const name = Redacted.value(token);
|
|
16
|
+
|
|
17
|
+
return isToken(name)
|
|
18
|
+
? Effect.succeed(members[name])
|
|
19
|
+
: Effect.fail(
|
|
20
|
+
new Action.Unauthenticated({ message: "Unknown demo token." })
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const authenticate = Authentication.layer(Login, verify);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { Rpc, RpcGroup } from "effect/rpc";
|
|
3
|
+
|
|
4
|
+
import { RoomPreview } from "./Lobby.js";
|
|
5
|
+
|
|
6
|
+
// The lobby's own API, over its stub: a room tells it its latest activity, and the Worker reads
|
|
7
|
+
// the previews. A plain Effect `RpcGroup`, not actions: no page calls it, so it needs no contract
|
|
8
|
+
// checks, no authentication and no JSON route, and nothing authenticates a stub call anyway, as
|
|
9
|
+
// only a binding reaches it. Why a report heard late never overwrites a later one is at
|
|
10
|
+
// `tellLobby`, in lobby-stubs.ts, which sends it.
|
|
11
|
+
export const LobbyCalls = RpcGroup.make(
|
|
12
|
+
Rpc.make("roomChanged", { payload: RoomPreview }),
|
|
13
|
+
Rpc.make("previews", { success: Schema.Array(RoomPreview) })
|
|
14
|
+
);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
import { RoomName } from "../chat/Chat.js";
|
|
6
|
+
|
|
7
|
+
// When one room was last active, in milliseconds, or null while it holds no message.
|
|
8
|
+
export const RoomPreview = Schema.Struct({
|
|
9
|
+
room: RoomName,
|
|
10
|
+
latestAt: Schema.NullOr(Schema.Int),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type RoomPreview = typeof RoomPreview.Type;
|
|
14
|
+
|
|
15
|
+
// The Worker's own action, served by the Worker, not by an object: it asks the lobby's object,
|
|
16
|
+
// which keeps every room's preview.
|
|
17
|
+
export const Previews = Action.make("previews", {
|
|
18
|
+
description:
|
|
19
|
+
"When each room was last active, in milliseconds, if it holds any message.",
|
|
20
|
+
success: { previews: Schema.Array(RoomPreview) },
|
|
21
|
+
readOnly: true,
|
|
22
|
+
caller: Action.Anyone,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Every room's preview, sent whole each time one changes: a page watches it on its socket to the
|
|
26
|
+
// lobby's object. Public, as `previews` is, so a page signed out follows it too.
|
|
27
|
+
export const PreviewsChanged = Event.make("previews.changed", {
|
|
28
|
+
description:
|
|
29
|
+
"When each room was last active, sent whole: first as it is, then on each change.",
|
|
30
|
+
payload: Schema.Struct({ previews: Schema.Array(RoomPreview) }),
|
|
31
|
+
caller: Action.Anyone,
|
|
32
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import { PreviewsChanged } from "./Lobby.js";
|
|
5
|
+
import { PreviewStore } from "./store.js";
|
|
6
|
+
|
|
7
|
+
// Every room's preview as one event, read from the lobby's store: a current source, for a value
|
|
8
|
+
// small enough to send whole. A page watching is sent the previews as they are, then again after
|
|
9
|
+
// each notify, which `roomChanged` makes after each change, so it needs no read before it and is
|
|
10
|
+
// never sent each change it missed, which a whole value makes needless. The object keeps the
|
|
11
|
+
// revision a page's cursor holds, so a page resuming at the latest is sent nothing.
|
|
12
|
+
export const previewEvents = Event.implement(
|
|
13
|
+
PreviewsChanged,
|
|
14
|
+
Effect.map(PreviewStore, (store) => ({
|
|
15
|
+
current: () => Effect.map(store.read, (previews) => ({ previews })),
|
|
16
|
+
}))
|
|
17
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
2
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
import { LobbyCalls } from "./Calls.js";
|
|
6
|
+
import { LobbyHost } from "./host.js";
|
|
7
|
+
import { Previews, PreviewsChanged } from "./Lobby.js";
|
|
8
|
+
import { PreviewStore } from "./store.js";
|
|
9
|
+
|
|
10
|
+
// The Worker's `previews`, as the lobby's object keeps them.
|
|
11
|
+
export const lobby = Action.implement(
|
|
12
|
+
Previews,
|
|
13
|
+
Effect.map(
|
|
14
|
+
LobbyHost,
|
|
15
|
+
(host) => () => Effect.map(host.previews, (previews) => ({ previews }))
|
|
16
|
+
)
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// The lobby's own API, in its object. A change notifies `PreviewsChanged`, which an object
|
|
20
|
+
// outside this one could not do itself: its event is this object's.
|
|
21
|
+
export const calls = LobbyCalls.toLayer(
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const store = yield* PreviewStore;
|
|
24
|
+
const events = yield* Event.Events;
|
|
25
|
+
|
|
26
|
+
return LobbyCalls.of({
|
|
27
|
+
roomChanged: ({ room, latestAt }) =>
|
|
28
|
+
Effect.flatMap(store.record(room, latestAt), (changed) =>
|
|
29
|
+
changed ? events.notify(PreviewsChanged) : Effect.void
|
|
30
|
+
),
|
|
31
|
+
previews: () => store.read,
|
|
32
|
+
});
|
|
33
|
+
})
|
|
34
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { RoomPreview } from "./Lobby.js";
|
|
5
|
+
|
|
6
|
+
/** The lobby's object as the Worker reaches it. */
|
|
7
|
+
export class LobbyHost extends Context.Service<
|
|
8
|
+
LobbyHost,
|
|
9
|
+
{ readonly previews: Effect.Effect<readonly RoomPreview[]> }
|
|
10
|
+
>()("chat-rooms/LobbyHost") {}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { RoomName } from "../chat/Chat.js";
|
|
5
|
+
import type { RoomPreview } from "./Lobby.js";
|
|
6
|
+
|
|
7
|
+
/** Every room's preview, as the room last told the lobby. */
|
|
8
|
+
export class PreviewStore extends Context.Service<
|
|
9
|
+
PreviewStore,
|
|
10
|
+
{
|
|
11
|
+
readonly read: Effect.Effect<readonly RoomPreview[]>;
|
|
12
|
+
/** Keeps `room`'s latest activity, succeeding with whether it changed. */
|
|
13
|
+
readonly record: (
|
|
14
|
+
room: RoomName,
|
|
15
|
+
latestAt: number | null
|
|
16
|
+
) => Effect.Effect<boolean>;
|
|
17
|
+
}
|
|
18
|
+
>()("chat-rooms/PreviewStore") {}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
2
|
+
import { Effect, Layer, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
import { RoomName } from "../chat/Chat.js";
|
|
5
|
+
import { RoomPreview } from "../lobby/Lobby.js";
|
|
6
|
+
import { PreviewStore } from "../lobby/store.js";
|
|
7
|
+
|
|
8
|
+
const decodeLatestAt = Schema.decodeUnknownSync(
|
|
9
|
+
Schema.UndefinedOr(RoomPreview.fields.latestAt)
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
// The previews in the lobby's object's synchronous key-value storage: each read and each record
|
|
13
|
+
// is one step no other call runs inside, so a record compares and writes what it read.
|
|
14
|
+
export const layerKvPreviews = Layer.effect(PreviewStore)(
|
|
15
|
+
Effect.map(ActionDurableObject.State, ({ storage: { kv } }) => {
|
|
16
|
+
const latestOf = (room: RoomName) =>
|
|
17
|
+
decodeLatestAt(kv.get(`preview:${room}`)) ?? null;
|
|
18
|
+
|
|
19
|
+
return PreviewStore.of({
|
|
20
|
+
read: Effect.sync(() =>
|
|
21
|
+
RoomName.literals.map((room) => ({ room, latestAt: latestOf(room) }))
|
|
22
|
+
),
|
|
23
|
+
record: (room, latestAt) =>
|
|
24
|
+
Effect.sync(() => {
|
|
25
|
+
if (latestOf(room) === latestAt) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
kv.put(`preview:${room}`, latestAt);
|
|
30
|
+
|
|
31
|
+
return true;
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
})
|
|
35
|
+
);
|