@gjermundgaraba/effect-actions-cloudflare 0.22.0 → 0.23.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.
Files changed (54) hide show
  1. package/dist/ActionDurableObject.d.mts +6 -0
  2. package/dist/ActionDurableObject.mjs +7 -7
  3. package/dist/alarm.mjs +5 -27
  4. package/dist/batches.mjs +0 -1
  5. package/dist/calls.mjs +0 -11
  6. package/dist/events.mjs +0 -15
  7. package/dist/frames.mjs +6 -9
  8. package/dist/idempotency.mjs +2 -8
  9. package/dist/placed.mjs +0 -4
  10. package/dist/sockets.d.mts +0 -4
  11. package/dist/sockets.mjs +7 -14
  12. package/dist/stub.d.mts +4 -4
  13. package/dist/stub.mjs +9 -8
  14. package/dist/subscriptions.mjs +10 -22
  15. package/dist/tasks.mjs +3 -8
  16. package/dist/webhooks.mjs +5 -21
  17. package/docs/ActionDurableObject-events.md +0 -20
  18. package/docs/ActionDurableObject-failures.md +1 -1
  19. package/docs/ActionDurableObject-placement.md +1 -4
  20. package/docs/ActionDurableObject-stub.md +3 -31
  21. package/docs/ActionDurableObject-testing.md +0 -5
  22. package/docs/ActionDurableObject.md +3 -49
  23. package/docs/README.md +1 -1
  24. package/examples/chat-rooms/README.md +2 -2
  25. package/examples/chat-rooms/src/Api.ts +0 -3
  26. package/examples/chat-rooms/src/Rpc.ts +0 -7
  27. package/examples/chat-rooms/src/browser.ts +2 -34
  28. package/examples/chat-rooms/src/chat/Chat.ts +3 -23
  29. package/examples/chat-rooms/src/chat/activity.ts +0 -5
  30. package/examples/chat-rooms/src/chat/authorize.ts +0 -3
  31. package/examples/chat-rooms/src/chat/events.ts +0 -12
  32. package/examples/chat-rooms/src/chat/handlers.ts +0 -6
  33. package/examples/chat-rooms/src/chat/room.ts +2 -7
  34. package/examples/chat-rooms/src/chat/store.ts +0 -6
  35. package/examples/chat-rooms/src/identity/Identity.ts +0 -6
  36. package/examples/chat-rooms/src/identity/members.ts +1 -3
  37. package/examples/chat-rooms/src/identity/verifier.ts +6 -9
  38. package/examples/chat-rooms/src/lobby/Calls.ts +0 -5
  39. package/examples/chat-rooms/src/lobby/Lobby.ts +2 -7
  40. package/examples/chat-rooms/src/lobby/events.ts +0 -5
  41. package/examples/chat-rooms/src/lobby/handlers.ts +0 -3
  42. package/examples/chat-rooms/src/lobby/host.ts +0 -1
  43. package/examples/chat-rooms/src/lobby/store.ts +2 -4
  44. package/examples/chat-rooms/src/platform/kv-previews.ts +6 -5
  45. package/examples/chat-rooms/src/platform/lobby-stubs.ts +0 -15
  46. package/examples/chat-rooms/src/platform/sql-store.ts +0 -11
  47. package/examples/chat-rooms/src/rooms-entrypoint.ts +2 -10
  48. package/examples/chat-rooms/src/routes.ts +0 -25
  49. package/examples/chat-rooms/src/worker.ts +3 -24
  50. package/examples/chat-rooms/test/platform/sql-store.test.ts +0 -5
  51. package/examples/chat-rooms/test/support/socket.ts +0 -9
  52. package/examples/chat-rooms/test/worker.test.ts +4 -18
  53. package/examples/chat-rooms/vite.config.ts +0 -5
  54. package/package.json +3 -3
@@ -5,19 +5,12 @@ import { authorize } from "./authorize.js";
5
5
  import { MessagePosted } from "./Chat.js";
6
6
  import { MessageStore } from "./store.js";
7
7
 
8
- // A cursor is the id of the last message a page holds, as `readMessages`' `after` is.
9
8
  const idOf = Schema.decodeUnknownOption(
10
9
  Schema.NumberFromString.pipe(Schema.check(Schema.isInt()))
11
10
  );
12
11
 
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
12
  const nextPoll = "30 seconds";
17
13
 
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
14
  export const messageEvents = Event.implement(
22
15
  MessagePosted,
23
16
  Effect.gen(function* () {
@@ -33,11 +26,6 @@ export const messageEvents = Event.implement(
33
26
  (id) => id >= 0 && id <= last
34
27
  );
35
28
 
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
29
  if (Option.isNone(after)) {
42
30
  return {
43
31
  cursor: String(last),
@@ -8,9 +8,6 @@ import { authorize } from "./authorize.js";
8
8
  import { MessagePosted, Messages } from "./Chat.js";
9
9
  import { MessageStore } from "./store.js";
10
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
11
  export const messages = Action.implement(
15
12
  Messages,
16
13
  Effect.gen(function* () {
@@ -21,9 +18,6 @@ export const messages = Action.implement(
21
18
  return {
22
19
  readMessages: ({ after }) =>
23
20
  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
21
  postMessage: ({ text }) =>
28
22
  Effect.flatMap(CurrentMember, (member) =>
29
23
  store.post(member.id, text)
@@ -1,12 +1,7 @@
1
1
  import { Context } from "effect";
2
2
 
3
- import type { RoomName } from "./Chat.js";
3
+ import type { DemoRoomName } from "./Chat.js";
4
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>()(
5
+ export class Room extends Context.Service<Room, DemoRoomName>()(
11
6
  "chat-rooms/Room"
12
7
  ) {}
@@ -3,16 +3,10 @@ import type { Effect } from "effect";
3
3
 
4
4
  import type { Message, MessageNotFound, NotAuthor } from "./Chat.js";
5
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
6
  export class MessageStore extends Context.Service<
12
7
  MessageStore,
13
8
  {
14
9
  readonly latest: Effect.Effect<Message | null>;
15
- /** The id of the last message ever posted, kept or not, or 0 before the first. */
16
10
  readonly last: Effect.Effect<number>;
17
11
  readonly read: (after: number) => Effect.Effect<readonly Message[]>;
18
12
  readonly post: (author: string, text: string) => Effect.Effect<Message>;
@@ -8,14 +8,8 @@ export const Member = Schema.Struct({
8
8
 
9
9
  export type Member = typeof Member.Type;
10
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
11
  export class CurrentMember extends Context.Service<CurrentMember, Member>()(
16
12
  "chat-rooms/CurrentMember"
17
13
  ) {}
18
14
 
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
15
  export const Login = Authentication.make("chat-rooms.Login", CurrentMember);
@@ -1,8 +1,6 @@
1
1
  import type { Member } from "./Identity.js";
2
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 = {
3
+ export const demoMembers = {
6
4
  ada: { id: "ada", rooms: ["general", "design"] },
7
5
  grace: { id: "grace", rooms: ["general"] },
8
6
  } satisfies Record<string, Member>;
@@ -3,22 +3,19 @@ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
3
  import { Effect, Redacted } from "effect";
4
4
 
5
5
  import { Login } from "./Identity.js";
6
- import { members } from "./members.js";
6
+ import { demoMembers } from "./members.js";
7
7
 
8
- const isToken = (token: string): token is keyof typeof members =>
9
- Object.hasOwn(members, token);
8
+ const isToken = (token: string): token is keyof typeof demoMembers =>
9
+ Object.hasOwn(demoMembers, token);
10
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) => {
11
+ const demoVerify = (token: Redacted.Redacted) => {
15
12
  const name = Redacted.value(token);
16
13
 
17
14
  return isToken(name)
18
- ? Effect.succeed(members[name])
15
+ ? Effect.succeed(demoMembers[name])
19
16
  : Effect.fail(
20
17
  new Action.Unauthenticated({ message: "Unknown demo token." })
21
18
  );
22
19
  };
23
20
 
24
- export const authenticate = Authentication.layer(Login, verify);
21
+ export const authenticate = Authentication.layer(Login, demoVerify);
@@ -3,11 +3,6 @@ import { Rpc, RpcGroup } from "effect/rpc";
3
3
 
4
4
  import { RoomPreview } from "./Lobby.js";
5
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
6
  export const LobbyCalls = RpcGroup.make(
12
7
  Rpc.make("roomChanged", { payload: RoomPreview }),
13
8
  Rpc.make("previews", { success: Schema.Array(RoomPreview) })
@@ -2,18 +2,15 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
2
2
  import * as Event from "@gjermundgaraba/effect-actions/Event";
3
3
  import { Schema } from "effect";
4
4
 
5
- import { RoomName } from "../chat/Chat.js";
5
+ import { DemoRoomName } from "../chat/Chat.js";
6
6
 
7
- // When one room was last active, in milliseconds, or null while it holds no message.
8
7
  export const RoomPreview = Schema.Struct({
9
- room: RoomName,
8
+ room: DemoRoomName,
10
9
  latestAt: Schema.NullOr(Schema.Int),
11
10
  });
12
11
 
13
12
  export type RoomPreview = typeof RoomPreview.Type;
14
13
 
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
14
  export const Previews = Action.make("previews", {
18
15
  description:
19
16
  "When each room was last active, in milliseconds, if it holds any message.",
@@ -22,8 +19,6 @@ export const Previews = Action.make("previews", {
22
19
  caller: Action.Anyone,
23
20
  });
24
21
 
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
22
  export const PreviewsChanged = Event.make("previews.changed", {
28
23
  description:
29
24
  "When each room was last active, sent whole: first as it is, then on each change.",
@@ -4,11 +4,6 @@ import { Effect } from "effect";
4
4
  import { PreviewsChanged } from "./Lobby.js";
5
5
  import { PreviewStore } from "./store.js";
6
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
7
  export const previewEvents = Event.implement(
13
8
  PreviewsChanged,
14
9
  Effect.map(PreviewStore, (store) => ({
@@ -7,7 +7,6 @@ import { LobbyHost } from "./host.js";
7
7
  import { Previews, PreviewsChanged } from "./Lobby.js";
8
8
  import { PreviewStore } from "./store.js";
9
9
 
10
- // The Worker's `previews`, as the lobby's object keeps them.
11
10
  export const lobby = Action.implement(
12
11
  Previews,
13
12
  Effect.map(
@@ -16,8 +15,6 @@ export const lobby = Action.implement(
16
15
  )
17
16
  );
18
17
 
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
18
  export const calls = LobbyCalls.toLayer(
22
19
  Effect.gen(function* () {
23
20
  const store = yield* PreviewStore;
@@ -3,7 +3,6 @@ import type { Effect } from "effect";
3
3
 
4
4
  import type { RoomPreview } from "./Lobby.js";
5
5
 
6
- /** The lobby's object as the Worker reaches it. */
7
6
  export class LobbyHost extends Context.Service<
8
7
  LobbyHost,
9
8
  { readonly previews: Effect.Effect<readonly RoomPreview[]> }
@@ -1,17 +1,15 @@
1
1
  import { Context } from "effect";
2
2
  import type { Effect } from "effect";
3
3
 
4
- import type { RoomName } from "../chat/Chat.js";
4
+ import type { DemoRoomName } from "../chat/Chat.js";
5
5
  import type { RoomPreview } from "./Lobby.js";
6
6
 
7
- /** Every room's preview, as the room last told the lobby. */
8
7
  export class PreviewStore extends Context.Service<
9
8
  PreviewStore,
10
9
  {
11
10
  readonly read: Effect.Effect<readonly RoomPreview[]>;
12
- /** Keeps `room`'s latest activity, succeeding with whether it changed. */
13
11
  readonly record: (
14
- room: RoomName,
12
+ room: DemoRoomName,
15
13
  latestAt: number | null
16
14
  ) => Effect.Effect<boolean>;
17
15
  }
@@ -1,7 +1,7 @@
1
1
  import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
2
2
  import { Effect, Layer, Schema } from "effect";
3
3
 
4
- import { RoomName } from "../chat/Chat.js";
4
+ import { DemoRoomName } from "../chat/Chat.js";
5
5
  import { RoomPreview } from "../lobby/Lobby.js";
6
6
  import { PreviewStore } from "../lobby/store.js";
7
7
 
@@ -9,16 +9,17 @@ const decodeLatestAt = Schema.decodeUnknownSync(
9
9
  Schema.UndefinedOr(RoomPreview.fields.latestAt)
10
10
  );
11
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
12
  export const layerKvPreviews = Layer.effect(PreviewStore)(
15
13
  Effect.map(ActionDurableObject.State, ({ storage: { kv } }) => {
16
- const latestOf = (room: RoomName) =>
14
+ const latestOf = (room: DemoRoomName) =>
17
15
  decodeLatestAt(kv.get(`preview:${room}`)) ?? null;
18
16
 
19
17
  return PreviewStore.of({
20
18
  read: Effect.sync(() =>
21
- RoomName.literals.map((room) => ({ room, latestAt: latestOf(room) }))
19
+ DemoRoomName.literals.map((room) => ({
20
+ room,
21
+ latestAt: latestOf(room),
22
+ }))
22
23
  ),
23
24
  record: (room, latestAt) =>
24
25
  Effect.sync(() => {
@@ -8,25 +8,14 @@ import { MessageStore } from "../chat/store.js";
8
8
  import { LobbyCalls } from "../lobby/Calls.js";
9
9
  import { LobbyHost } from "../lobby/host.js";
10
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
11
  const lobbyStub = () => env.CHAT_LOBBY.getByName("lobby");
14
12
 
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
13
  export const layerLobbyHost = Layer.effect(LobbyHost)(
19
14
  Effect.map(ActionDurableObject.client(LobbyCalls), (lobby) =>
20
15
  LobbyHost.of({ previews: Effect.orDie(lobby(lobbyStub).previews()) })
21
16
  )
22
17
  );
23
18
 
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
19
  export const tellLobby = Effect.gen(function* () {
31
20
  const room = yield* Room;
32
21
  const latest = yield* MessageStore.use((store) => store.latest);
@@ -35,10 +24,6 @@ export const tellLobby = Effect.gen(function* () {
35
24
  yield* lobby(lobbyStub).roomChanged({ room, latestAt: latest?.at ?? null });
36
25
  });
37
26
 
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
27
  export const layerActivity = Layer.effect(Activity)(
43
28
  Effect.map(ActionDurableObject.Tasks, (tasks) =>
44
29
  Activity.of({ changing: (change) => tasks.runAfter("lobby", change) })
@@ -12,9 +12,6 @@ const decodeSequence = Schema.decodeUnknownSync(
12
12
  Schema.Array(Schema.Struct({ seq: Schema.Int }))
13
13
  );
14
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
15
  export const layerSql = Layer.effect(MessageStore)(
19
16
  Effect.gen(function* () {
20
17
  const { storage } = yield* ActionDurableObject.State;
@@ -30,16 +27,10 @@ export const layerSql = Layer.effect(MessageStore)(
30
27
  "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, text TEXT NOT NULL, at INTEGER NOT NULL)"
31
28
  );
32
29
 
33
- // Every expiry 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
30
  storage.sql.exec(
36
31
  "CREATE INDEX IF NOT EXISTS messages_by_at ON messages (at)"
37
32
  );
38
33
 
39
- // The room's alarm is due no later than the oldest message's expiry: `Alarm.set` keeps the
40
- // earliest time it is given, so each post sets its own message's, and `expire`, which runs on
41
- // the alarm, sets the next from the oldest message left. Removing the oldest message leaves
42
- // its alarm, which then finds nothing due and moves on.
43
34
  const alarmForOldest = Effect.suspend(() => {
44
35
  const [oldest] = query("SELECT * FROM messages ORDER BY at LIMIT 1");
45
36
 
@@ -53,8 +44,6 @@ export const layerSql = Layer.effect(MessageStore)(
53
44
  () =>
54
45
  query("SELECT * FROM messages ORDER BY id DESC LIMIT 1")[0] ?? null
55
46
  ),
56
- // AUTOINCREMENT keeps the highest id it ever gave in `sqlite_sequence`, so the last id
57
- // outlives the message: one that expired or was deleted still counts.
58
47
  last: Effect.sync(
59
48
  () =>
60
49
  decodeSequence(
@@ -1,21 +1,13 @@
1
1
  import { WorkerEntrypoint } from "cloudflare:workers";
2
2
  import { Effect, Schema } from "effect";
3
3
 
4
- import { RoomName } from "./chat/Chat.js";
4
+ import { DemoRoomName } from "./chat/Chat.js";
5
5
  import { LobbyHost } from "./lobby/host.js";
6
6
  import { layerLobbyHost } from "./platform/lobby-stubs.js";
7
7
 
8
- const decodeRoom = Schema.decodeUnknownEffect(RoomName);
8
+ const decodeRoom = Schema.decodeUnknownEffect(DemoRoomName);
9
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
10
  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
11
  async latestAt(room: string) {
20
12
  return await Effect.runPromise(
21
13
  Effect.flatMap(decodeRoom(room), (name) =>
@@ -25,9 +25,6 @@ const room = [messages, messageEvents];
25
25
 
26
26
  const byRoom = (input: { readonly room: string }) => input.room;
27
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
28
  export const rooms = ActionDurableObject.placed(RoomHttp, {
32
29
  readMessages: byRoom,
33
30
  postMessage: byRoom,
@@ -35,48 +32,26 @@ export const rooms = ActionDurableObject.placed(RoomHttp, {
35
32
  "message.posted": byRoom,
36
33
  });
37
34
 
38
- // One socket route for each object's pages, refusing a page of another version.
39
35
  const pageSocket = ActionDurableObject.layerWebSocket({
40
36
  path: "/rpc",
41
37
  protocol: { version: protocolVersion, code: unsupportedProtocol },
42
38
  });
43
39
 
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
40
  export const roomRoutes = Layer.mergeAll(
53
41
  ActionRpc.layer(RoomRpc, room).pipe(Layer.provide(pageSocket)),
54
42
  ActionHttp.layer(RoomHttp, room)
55
43
  ).pipe(Layer.provide(authenticate));
56
44
 
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
45
  export const lobbyRoutes = ActionRpc.layer(LobbyRpc, [previewEvents]).pipe(
60
46
  Layer.provide(pageSocket)
61
47
  );
62
48
 
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
49
  export const lobbyCalls = RpcServer.layer(LobbyCalls).pipe(
67
50
  Layer.provide(lobbyHandlers)
68
51
  );
69
52
 
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
53
  const inRooms = ActionDurableObject.reaching(rooms, () => env.CHAT_ROOM);
73
54
 
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
55
  export const workerRoutes = Layer.mergeAll(
81
56
  ActionHttp.layer(LobbyHttp, lobby),
82
57
  ActionHttp.layer(RoomHttp, inRooms),
@@ -3,7 +3,7 @@ import { Effect, Layer, Schema } from "effect";
3
3
  import { HttpRouter, HttpServer } from "effect/http";
4
4
 
5
5
  import { Activity } from "./chat/activity.js";
6
- import { RoomName } from "./chat/Chat.js";
6
+ import { DemoRoomName } from "./chat/Chat.js";
7
7
  import { Room } from "./chat/room.js";
8
8
  import { MessageStore } from "./chat/store.js";
9
9
  import { layerKvPreviews } from "./platform/kv-previews.js";
@@ -21,22 +21,10 @@ import {
21
21
  workerRoutes,
22
22
  } from "./routes.js";
23
23
 
24
- // The rooms for another Worker, over a service binding: a named entrypoint of this one.
25
24
  export { RoomsEntrypoint } from "./rooms-entrypoint.js";
26
25
 
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.
26
+ const decodeRoom = Schema.decodeUnknownEffect(DemoRoomName);
29
27
 
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
28
  export class ChatRoom extends ActionDurableObject.make({
41
29
  placed: rooms,
42
30
  services: () =>
@@ -53,18 +41,12 @@ export class ChatRoom extends ActionDurableObject.make({
53
41
  ),
54
42
  }) {}
55
43
 
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
44
  export class ChatLobby extends ActionDurableObject.make({
59
45
  services: () => layerKvPreviews,
60
46
  routes: lobbyRoutes,
61
47
  calls: lobbyCalls,
62
48
  }) {}
63
49
 
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
50
  const served = HttpRouter.toWebHandler(
69
51
  workerRoutes.pipe(
70
52
  Layer.provide(layerLobbyHost),
@@ -72,11 +54,8 @@ const served = HttpRouter.toWebHandler(
72
54
  )
73
55
  );
74
56
 
75
- const isRoomName = Schema.is(RoomName);
57
+ const isRoomName = Schema.is(DemoRoomName);
76
58
 
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
59
  export default {
81
60
  async fetch(request: Request, env: Cloudflare.Env) {
82
61
  const url = new URL(request.url);
@@ -12,8 +12,6 @@ const day = 24 * 60 * 60 * 1000;
12
12
 
13
13
  let objects = 0;
14
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
15
  const inObject = <A, E>(
18
16
  use: (
19
17
  store: MessageStore["Service"],
@@ -39,9 +37,6 @@ const inObject = <A, E>(
39
37
  );
40
38
  });
41
39
 
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
40
  const tickingFrom = (start: number): Clock.Clock => {
46
41
  let now = start;
47
42
  const tick = () => (now += 1);
@@ -6,14 +6,6 @@ import { Socket } from "effect/socket";
6
6
 
7
7
  import { protocolVersion, unsupportedProtocol } from "../../src/Rpc.js";
8
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
9
  export const pageSocket = (
18
10
  at: string,
19
11
  headers: Record<string, string> = {},
@@ -62,7 +54,6 @@ export const pageSocket = (
62
54
  return { socket, protocol, connects: () => connects };
63
55
  });
64
56
 
65
- // A page's socket to a room.
66
57
  export const roomSocket = (
67
58
  room: string,
68
59
  headers: Record<string, string> = {},
@@ -14,17 +14,15 @@ import { TestClock } from "effect/testing";
14
14
  import { LobbyHttp, RoomHttp } from "../src/Api.js";
15
15
  import { followLobby, openRoom } from "../src/browser.js";
16
16
  import { MessagePosted, PostMessage } from "../src/chat/Chat.js";
17
- import type { RoomName } from "../src/chat/Chat.js";
17
+ import type { DemoRoomName } from "../src/chat/Chat.js";
18
18
  import { Login } from "../src/identity/Identity.js";
19
19
  import { RoomRpc, unsupportedProtocol } from "../src/Rpc.js";
20
20
  import { pageSocket, roomSocket } from "./support/socket.js";
21
21
 
22
22
  const day = 24 * 60 * 60 * 1000;
23
23
 
24
- // Every request goes through the Worker's own `fetch`, as a deployed one would get it.
25
24
  const worker = ActionDurableObject.layerHttpClient(exports.default);
26
25
 
27
- // The rooms' JSON routes, the Worker's: each call names its room.
28
26
  const jsonAs = (token?: string) =>
29
27
  ActionHttp.client(RoomHttp, {
30
28
  baseUrl: "https://chat.example",
@@ -37,7 +35,6 @@ const jsonAs = (token?: string) =>
37
35
  }),
38
36
  });
39
37
 
40
- // An agent's one connection to the Worker's MCP endpoint, reaching every room.
41
38
  const agentAs = (token: string) =>
42
39
  ActionMcp.client([PostMessage, MessagePosted], {
43
40
  url: "https://chat.example/mcp",
@@ -47,8 +44,6 @@ const agentAs = (token: string) =>
47
44
  ),
48
45
  });
49
46
 
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
47
  const eventually = <A, E>(
53
48
  check: Effect.Effect<A, E>,
54
49
  until: (value: A) => boolean
@@ -65,7 +60,7 @@ const arrivedIn = (received: readonly unknown[]) => (count: number) =>
65
60
  );
66
61
 
67
62
  const pageIn = (
68
- room: RoomName,
63
+ room: DemoRoomName,
69
64
  token: string,
70
65
  headers: Record<string, string> = {}
71
66
  ) =>
@@ -92,10 +87,7 @@ const status = (path: string, body: Schema.Json = {}) =>
92
87
 
93
88
  const readAll = (room: string) => ({ room, after: 0 });
94
89
 
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", () => {
90
+ describe("the Worker's handler, built as the module loads, before any other test of this file reaches the lobby", () => {
99
91
  it.effect(
100
92
  "answers a first request ending without I/O, then a call reaching the lobby",
101
93
  () =>
@@ -300,7 +292,7 @@ describe("the Worker and its rooms", () => {
300
292
  text: "Polled in design.",
301
293
  });
302
294
 
303
- const polled = (room: RoomName, after: number) =>
295
+ const polled = (room: DemoRoomName, after: number) =>
304
296
  ada
305
297
  .watch(
306
298
  MessagePosted,
@@ -409,8 +401,6 @@ describe("the Worker and its rooms", () => {
409
401
  baseUrl: "https://chat.example",
410
402
  });
411
403
 
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
404
  const { previews } = yield* eventually(lobby.previews(), (answer) =>
415
405
  answer.previews.some(
416
406
  ({ room, latestAt }) => room === "design" && latestAt === posted.at
@@ -487,7 +477,6 @@ describe("the Worker and its rooms", () => {
487
477
  )
488
478
  );
489
479
 
490
- // The lobby hears of the post from the room's `lobby` task, a moment after it answered.
491
480
  const latestAt = yield* eventually(
492
481
  Effect.promise(
493
482
  async () => await exports.RoomsEntrypoint.latestAt("general")
@@ -541,9 +530,6 @@ describe("the Worker and its rooms", () => {
541
530
  text: "From today.",
542
531
  });
543
532
 
544
- // A day passes for the first message alone: it is backdated, and the alarm set for its
545
- // expiry, as its post would have set it a day ago. That time is past, so the runtime runs
546
- // it at once: the test waits until it has set the next, for the recent one's.
547
533
  yield* Effect.promise(async () => {
548
534
  await runInDurableObject(stub, async (room, state) => {
549
535
  state.storage.sql.exec(