@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
package/dist/webhooks.mjs CHANGED
@@ -4,12 +4,7 @@ import { Clock, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema
4
4
  import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
5
5
  import { KeyValueStore } from "effect/persistence";
6
6
  // src/durable/webhooks.ts
7
- /** The owner of the object's alarm times that end its long waits. */
8
7
  const clockOwner = "webhooks";
9
- /**
10
- * Where `layerWebhooks` keeps its subscriptions in the object's storage: every key of its store,
11
- * whatever layout `Webhook.layer`'s `persist` gives them, is under it.
12
- */
13
8
  const webhookPrefix = "effect-actions/webhooks/";
14
9
  /**
15
10
  * The instance's long waits, which the object's alarm ends: `until(at)` waits until the alarm
@@ -17,13 +12,9 @@ const webhookPrefix = "effect-actions/webhooks/";
17
12
  * `make` provide it to the routes, over the instance's schedule, for `layerWebhooks`.
18
13
  */
19
14
  var Waits = class extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Waits") {};
20
- /**
21
- * The instance's `Waits`, their time set on `schedule`, the one its `alarm` handler runs, and
22
- * `wakeDue`, which that handler runs at their time.
23
- */
24
15
  const makeWaits = (schedule) => {
25
16
  const held = /* @__PURE__ */ new Set();
26
- const armed = Semaphore.makeUnsafe(1).withPermits(1)(Effect.suspend(() => held.size === 0 ? schedule.clear(clockOwner) : schedule.set(clockOwner, Math.min(...[...held].map(({ at }) => at)))));
17
+ const armForEarliestWait = Semaphore.makeUnsafe(1).withPermits(1)(Effect.suspend(() => held.size === 0 ? schedule.clear(clockOwner) : schedule.set(clockOwner, Math.min(...[...held].map(({ at }) => at)))));
27
18
  return {
28
19
  waits: Waits.of({ until: (at) => Effect.suspend(() => {
29
20
  const wait = {
@@ -31,25 +22,19 @@ const makeWaits = (schedule) => {
31
22
  woken: Deferred.makeUnsafe()
32
23
  };
33
24
  held.add(wait);
34
- return Effect.andThen(armed, Deferred.await(wait.woken)).pipe(Effect.ensuring(Effect.suspend(() => held.delete(wait) ? armed : Effect.void)));
25
+ return Effect.andThen(armForEarliestWait, Deferred.await(wait.woken)).pipe(Effect.ensuring(Effect.suspend(() => held.delete(wait) ? armForEarliestWait : Effect.void)));
35
26
  }) }),
36
- /** Ends each wait that is due, and sets the alarm for the next. */
37
27
  wakeDue: Effect.gen(function* () {
38
28
  const now = yield* Clock.currentTimeMillis;
39
29
  for (const wait of held) if (wait.at <= now) {
40
30
  held.delete(wait);
41
31
  Deferred.doneUnsafe(wait.woken, Exit.void);
42
32
  }
43
- yield* armed;
33
+ yield* armForEarliestWait;
44
34
  })
45
35
  };
46
36
  };
47
- const timed = Duration.seconds(10);
48
- /**
49
- * The Clock the webhook layer runs on: a wait of 10 seconds or more waits on the object's alarm
50
- * and holds no timer, so the object may hibernate meanwhile; if the instance is evicted, the
51
- * alarm builds the next, which restores the subscriptions from storage.
52
- */
37
+ const alarmWaitThreshold = Duration.seconds(10);
53
38
  const alarmClock = (base, { until }) => ({
54
39
  currentTimeMillisUnsafe: () => base.currentTimeMillisUnsafe(),
55
40
  currentTimeMillis: base.currentTimeMillis,
@@ -57,7 +42,7 @@ const alarmClock = (base, { until }) => ({
57
42
  currentTimeNanos: base.currentTimeNanos,
58
43
  monotonicTimeNanosUnsafe: () => base.monotonicTimeNanosUnsafe(),
59
44
  monotonicTimeNanos: base.monotonicTimeNanos,
60
- sleep: (duration) => Duration.isLessThan(duration, timed) || !Duration.isFinite(duration) ? base.sleep(duration) : until(base.currentTimeMillisUnsafe() + Duration.toMillis(duration))
45
+ sleep: (duration) => Duration.isLessThan(duration, alarmWaitThreshold) || !Duration.isFinite(duration) ? base.sleep(duration) : until(base.currentTimeMillisUnsafe() + Duration.toMillis(duration))
61
46
  });
62
47
  const stringOf = Schema.decodeUnknownOption(Schema.String);
63
48
  const attempted = (method, key, run) => Effect.tryPromise({
@@ -70,7 +55,6 @@ const attempted = (method, key, run) => Effect.tryPromise({
70
55
  })
71
56
  });
72
57
  const keyOf = (key) => `${webhookPrefix}${key}`;
73
- /** A `KeyValueStore` over the object's storage under `webhookPrefix`, every key of it there. */
74
58
  const storageStore = (storage) => {
75
59
  const listed = attempted("list", void 0, async () => await storage.list({ prefix: webhookPrefix }));
76
60
  return KeyValueStore.makeStringOnly({
@@ -14,19 +14,12 @@ import { authorize } from "./authorize.js";
14
14
  import { MessagePosted } from "./Chat.js";
15
15
  import { MessageStore } from "./store.js";
16
16
 
17
- // A cursor is the id of the last message a page holds, as `readMessages`' `after` is.
18
17
  const idOf = Schema.decodeUnknownOption(
19
18
  Schema.NumberFromString.pipe(Schema.check(Schema.isInt()))
20
19
  );
21
20
 
22
- // An agent polling over MCP asks again after `nextPoll`, 5 seconds by default: a request that
23
- // often keeps the object from the 10 idle seconds after which the runtime may hibernate it, so it
24
- // asks every 30 seconds, and an agent that needs a message sooner holds a stream open instead.
25
21
  const nextPoll = "30 seconds";
26
22
 
27
- // The room's messages as an event, read from the store that keeps them: a check source.
28
- // `postMessage` notifies after each post, so a page receives it at once: when an object reads a
29
- // check source, and what a write that does not notify misses, is on `ActionDurableObject.handlers`.
30
23
  export const messageEvents = Event.implement(
31
24
  MessagePosted,
32
25
  Effect.gen(function* () {
@@ -42,11 +35,6 @@ export const messageEvents = Event.implement(
42
35
  (id) => id >= 0 && id <= last
43
36
  );
44
37
 
45
- // No cursor starts after the room's last message. One this room never gave, such as
46
- // an id past its last from another copy of its database, is answered `truncated` from
47
- // there: the page learns it missed messages, and reads them with `readMessages`. A
48
- // message deleted or expired after a cursor is simply not read: the store keeps no
49
- // record of either, so no gap marks it.
50
38
  if (Option.isNone(after)) {
51
39
  return {
52
40
  cursor: String(last),
@@ -89,7 +77,6 @@ import { LobbyHost } from "./host.js";
89
77
  import { Previews, PreviewsChanged } from "./Lobby.js";
90
78
  import { PreviewStore } from "./store.js";
91
79
 
92
- // The Worker's `previews`, as the lobby's object keeps them.
93
80
  export const lobby = Action.implement(
94
81
  Previews,
95
82
  Effect.map(
@@ -98,8 +85,6 @@ export const lobby = Action.implement(
98
85
  )
99
86
  );
100
87
 
101
- // The lobby's own API, in its object. A change notifies `PreviewsChanged`, which an object
102
- // outside this one could not do itself: its event is this object's.
103
88
  export const calls = LobbyCalls.toLayer(
104
89
  Effect.gen(function* () {
105
90
  const store = yield* PreviewStore;
@@ -125,11 +110,6 @@ import { Effect } from "effect";
125
110
  import { PreviewsChanged } from "./Lobby.js";
126
111
  import { PreviewStore } from "./store.js";
127
112
 
128
- // Every room's preview as one event, read from the lobby's store: a current source, for a value
129
- // small enough to send whole. A page watching is sent the previews as they are, then again after
130
- // each notify, which `roomChanged` makes after each change, so it needs no read before it and is
131
- // never sent each change it missed, which a whole value makes needless. The object keeps the
132
- // revision a page's cursor holds, so a page resuming at the latest is sent nothing.
133
113
  export const previewEvents = Event.implement(
134
114
  PreviewsChanged,
135
115
  Effect.map(PreviewStore, (store) => ({
@@ -15,4 +15,4 @@ Messages the library does not own, workerd's, the compiler's and Effect's, and t
15
15
  - `Could not serialize object of type "<Class>". This type does not support serialization`, workerd's `DataCloneError`, as an `RpcClientError`'s cause: a value of the call, success, error or defect, is one structured clone refuses, such as a class instance in a `Schema.Unknown`; declare its schema, whose plain form clones, or die with an `Error`.
16
16
  - `Your Durable Object class must have an alarm() handler in order to call setAlarm()`, workerd's, from `storage.setAlarm`, which `Alarm.set` calls: the class delegating to `handlers` defines no `alarm`; delegate `handlers`' own, as `make`'s classes do, and on celld 0.6.2, Deno's self-hosted Durable Object runtime, which accepts the call and drops the alarm when it is due, an alarm that never runs means the same.
17
17
  - `The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response`, workerd's, from every call through a client kept across a Worker's requests, `client` or `RpcClient.make` over `layerStubClient`, or through a handler or runtime holding one: it was first built lazily inside a request that ended without waiting on I/O, succeeding or refused, whose timer would have started the client's own fiber; run each call as its own program, or share it built at module scope under Effect's sync scheduler, as `HttpRouter.toWebHandler`'s gotchas show, a `ManagedRuntime.make` there building only at its first run, as `ActionDurableObject.layerStubClient`'s gotchas say.
18
- - `RpcClientError` from every call over a stub, Effect's own error: the stub failed, its `reason.cause` the runtime's or the object's message; a stub an exception broke fails every later call with the same one, so call through `client`, or give `layerStubClient` a function, `() => stub`, either making a new stub for each call.
18
+ - `RpcClientError` from every call over a stub, Effect's own error: the stub failed, its `reason.cause` the runtime's or the object's message; a stub an exception broke fails every later call with the same one, so call through `client` or `layerStubClient`, either making a new stub for each call from its function.
@@ -13,13 +13,10 @@ import { MessagePosted, Messages } from "./chat/Chat.js";
13
13
  import { Login } from "./identity/Identity.js";
14
14
  import { Previews } from "./lobby/Lobby.js";
15
15
 
16
- // The rooms' JSON routes, at `/api/<action>` and `/api/events/<name>`: the Worker's, which a
17
- // client without a socket calls, and each room's object's, where the Worker sends each call on.
18
16
  export const RoomHttp = ActionHttp.make([...Messages, MessagePosted], {
19
17
  authentication: Login,
20
18
  });
21
19
 
22
- // The Worker's own JSON routes.
23
20
  export const LobbyHttp = ActionHttp.make([Previews]);
24
21
  ```
25
22
 
@@ -30,7 +27,7 @@ Then, as `routes.ts` on [ActionDurableObject.md](ActionDurableObject.md) does:
30
27
  - The class is given the placement as `make`'s `placed`, in `worker.ts`: the object then refuses `Forbidden`, on every surface, its socket and its stub included, a call or a watch whose key names another object, so a page's socket to one room cannot reach another.
31
28
  - The Worker serves `ActionDurableObject.reaching(rooms, () => env.CHAT_ROOM)` beside its own implementations, in `ActionHttp.layer` and `ActionMcp.layerHttp`, each call sent to the room's object with the caller's headers.
32
29
 
33
- The Worker authenticates each protected call as its own, and the object authenticates and authorizes it again, so both are given the verifier. A key's schema, `RoomName` in `Chat.ts`, is what keeps a caller from creating objects: an unknown room is `InvalidInput` before any object is reached. [chat-rooms/test/worker.test.ts](../examples/chat-rooms/test/worker.test.ts) reaches two rooms through one MCP connection and has a room refuse a call naming another.
30
+ The Worker authenticates each protected call as its own, and the object authenticates and authorizes it again, so both are given the verifier. A key's schema, `DemoRoomName` in `Chat.ts`, is what keeps a caller from creating objects: an unknown room is `InvalidInput` before any object is reached. [chat-rooms/test/worker.test.ts](../examples/chat-rooms/test/worker.test.ts) reaches two rooms through one MCP connection and has a room refuse a call naming another.
34
31
 
35
32
  ## Serve them over MCP from the Worker
36
33
 
@@ -12,11 +12,6 @@ import { Rpc, RpcGroup } from "effect/rpc";
12
12
 
13
13
  import { RoomPreview } from "./Lobby.js";
14
14
 
15
- // The lobby's own API, over its stub: a room tells it its latest activity, and the Worker reads
16
- // the previews. A plain Effect `RpcGroup`, not actions: no page calls it, so it needs no contract
17
- // checks, no authentication and no JSON route, and nothing authenticates a stub call anyway, as
18
- // only a binding reaches it. Why a report heard late never overwrites a later one is at
19
- // `tellLobby`, in lobby-stubs.ts, which sends it.
20
15
  export const LobbyCalls = RpcGroup.make(
21
16
  Rpc.make("roomChanged", { payload: RoomPreview }),
22
17
  Rpc.make("previews", { success: Schema.Array(RoomPreview) })
@@ -25,7 +20,7 @@ export const LobbyCalls = RpcGroup.make(
25
20
 
26
21
  Its handlers read the store the instance built ([chat-rooms/src/lobby/handlers.ts](../examples/chat-rooms/src/lobby/handlers.ts)), and `RpcServer.layer(LobbyCalls)` over them is the object's `calls`, beside its routes ([chat-rooms/src/routes.ts](../examples/chat-rooms/src/routes.ts)), given to `make` in [chat-rooms/src/worker.ts](../examples/chat-rooms/src/worker.ts). `make` gives the calls their protocol; what crosses it, as values and not JSON, what it refuses and what the calls may require are on `ActionDurableObject.Options`' `calls`.
27
22
 
28
- A plain group suits an object's own API, which no page calls. Actions served on other surfaces too are served the same way, `calls: ActionRpc.layer(RoomRpc, room)`, and called with `ActionRpc.client(RoomRpc)` over `layerStubClient(env.CHAT_ROOM.getByName(room))`: their values cross as JSON, and a protected action authenticates each call from its headers, so the caller forwards the user's credential with `RpcClient.withHeaders`.
23
+ A plain group suits an object's own API, which no page calls. Actions served on other surfaces too are served the same way, `calls: ActionRpc.layer(RoomRpc, room)`, and called with `ActionRpc.client(RoomRpc)` over `layerStubClient(() => env.CHAT_ROOM.getByName(room))`: their values cross as JSON, and a protected action authenticates each call from its headers, so the caller forwards the user's credential with `RpcClient.withHeaders`.
29
24
 
30
25
  ## Call it from a Worker or another object
31
26
 
@@ -42,25 +37,14 @@ import { MessageStore } from "../chat/store.js";
42
37
  import { LobbyCalls } from "../lobby/Calls.js";
43
38
  import { LobbyHost } from "../lobby/host.js";
44
39
 
45
- // The lobby is one object, named `lobby`: a new stub for each call, read from `env` as the call
46
- // is sent, never as the module loads.
47
40
  const lobbyStub = () => env.CHAT_LOBBY.getByName("lobby");
48
41
 
49
- // The lobby's object as the Worker reaches it, for the previews: a lobby that cannot be reached
50
- // is the deployment's failure, not the caller's, so it is a defect, the Worker answers 500, and
51
- // the cause is in its logs.
52
42
  export const layerLobbyHost = Layer.effect(LobbyHost)(
53
43
  Effect.map(ActionDurableObject.client(LobbyCalls), (lobby) =>
54
44
  LobbyHost.of({ previews: Effect.orDie(lobby(lobbyStub).previews()) })
55
45
  )
56
46
  );
57
47
 
58
- // The room's task, worker.ts's `lobby`: telling the lobby the room's latest activity. The room's
59
- // alarm runs its tasks one run at a time, each waiting for the lobby's answer, and each run reads
60
- // the activity as it is then: the lobby hears a room's reports in the order the room made them,
61
- // so a report heard late never overwrites a later one, and the last it hears is the room's
62
- // latest. A run repeated after a lost answer sends what the lobby holds, which `record` finds
63
- // unchanged, notifying no page.
64
48
  export const tellLobby = Effect.gen(function* () {
65
49
  const room = yield* Room;
66
50
  const latest = yield* MessageStore.use((store) => store.latest);
@@ -69,10 +53,6 @@ export const tellLobby = Effect.gen(function* () {
69
53
  yield* lobby(lobbyStub).roomChanged({ room, latestAt: latest?.at ?? null });
70
54
  });
71
55
 
72
- // A room's activity, a startup service of the room's: each change runs the `lobby` task after it,
73
- // on the room's alarm once the call has answered, and again until the lobby hears it, so a lobby
74
- // that cannot be reached fails no post and misses no change. `runAfter` marks the task before the
75
- // change and again after it, so a crash at any point leaves the task to the alarm.
76
56
  export const layerActivity = Layer.effect(Activity)(
77
57
  Effect.map(ActionDurableObject.Tasks, (tasks) =>
78
58
  Activity.of({ changing: (change) => tasks.runAfter("lobby", change) })
@@ -88,21 +68,13 @@ A Worker's entrypoint that is no Effect program, such as a `WorkerEntrypoint` me
88
68
  import { WorkerEntrypoint } from "cloudflare:workers";
89
69
  import { Effect, Schema } from "effect";
90
70
 
91
- import { RoomName } from "./chat/Chat.js";
71
+ import { DemoRoomName } from "./chat/Chat.js";
92
72
  import { LobbyHost } from "./lobby/host.js";
93
73
  import { layerLobbyHost } from "./platform/lobby-stubs.js";
94
74
 
95
- const decodeRoom = Schema.decodeUnknownEffect(RoomName);
75
+ const decodeRoom = Schema.decodeUnknownEffect(DemoRoomName);
96
76
 
97
- // The rooms' activity for another Worker, over a service binding to this one's `RoomsEntrypoint`,
98
- // which worker.ts exports, as the lobby keeps it. A method of a `WorkerEntrypoint` is no Effect
99
- // program, so each call runs as one, given the lobby's Layer: its client is built in the request
100
- // that uses it and closed with it, which costs no I/O. Why a client kept across requests strands
101
- // is on `ActionDurableObject.layerStubClient`.
102
77
  export class RoomsEntrypoint extends WorkerEntrypoint {
103
- // The calling Worker is another deployment, so its room is checked as any input is: an unknown
104
- // room rejects the call with the schema's error, as a lobby that cannot be reached rejects it
105
- // with the defect lobby-stubs.ts makes of it.
106
78
  async latestAt(room: string) {
107
79
  return await Effect.runPromise(
108
80
  Effect.flatMap(decodeRoom(room), (name) =>
@@ -10,7 +10,6 @@ Run the tests inside the runtime with `@cloudflare/vitest-plugin`, on the Worker
10
10
  import { cloudflareTest } from "@cloudflare/vitest-plugin";
11
11
  import { defineConfig } from "vite-plus";
12
12
 
13
- // Its tests run inside the Workers runtime, on the Worker wrangler.jsonc describes.
14
13
  export default defineConfig({
15
14
  plugins: [
16
15
  cloudflareTest({
@@ -19,10 +18,6 @@ export default defineConfig({
19
18
  ],
20
19
  test: {
21
20
  include: ["test/**/*.test.ts"],
22
- // Why: under @cloudflare/vitest-plugin 1.4.4, the warm-module response for a later file in a
23
- // pool slot can exceed what the runner's control socket accepts, 41 MB in 709 entries
24
- // measured on chat-rooms, which closes it with 1009 and loses the file as "Worker exited
25
- // unexpectedly"; two small files alone did not trigger it.
26
21
  fsModuleCache: false,
27
22
  },
28
23
  });
@@ -14,7 +14,7 @@ import { Effect, Layer, Schema } from "effect";
14
14
  import { HttpRouter, HttpServer } from "effect/http";
15
15
 
16
16
  import { Activity } from "./chat/activity.js";
17
- import { RoomName } from "./chat/Chat.js";
17
+ import { DemoRoomName } from "./chat/Chat.js";
18
18
  import { Room } from "./chat/room.js";
19
19
  import { MessageStore } from "./chat/store.js";
20
20
  import { layerKvPreviews } from "./platform/kv-previews.js";
@@ -32,22 +32,10 @@ import {
32
32
  workerRoutes,
33
33
  } from "./routes.js";
34
34
 
35
- // The rooms for another Worker, over a service binding: a named entrypoint of this one.
36
35
  export { RoomsEntrypoint } from "./rooms-entrypoint.js";
37
36
 
38
- // Entrypoint, Wrangler's `main`: the composition root of the Worker and of every object. It
39
- // chooses the stores, the room and the stubs, and routes each request.
37
+ const decodeRoom = Schema.decodeUnknownEffect(DemoRoomName);
40
38
 
41
- const decodeRoom = Schema.decodeUnknownEffect(RoomName);
42
-
43
- // A room's Durable Object, bound as CHAT_ROOM in wrangler.jsonc under this export's name. Each
44
- // instance builds its services, the store, the room and its activity, then its routes, as it is
45
- // constructed, again after every eviction. The room is the name the Worker reached the object by,
46
- // `getByName(room)`, decoded once there: `ActionDurableObject.name` dies for an object reached by
47
- // an id, and the build fails for a name `RoomName` does not list, each a bug in the Worker. Given
48
- // `rooms`, it refuses a call or a watch whose input names another room. Its `lobby` task tells
49
- // the lobby of each change, on the alarm until the lobby hears. The alarm expires old messages
50
- // and tells the lobby, as an expired message can be the room's latest.
51
39
  export class ChatRoom extends ActionDurableObject.make({
52
40
  placed: rooms,
53
41
  services: () =>
@@ -64,18 +52,12 @@ export class ChatRoom extends ActionDurableObject.make({
64
52
  ),
65
53
  }) {}
66
54
 
67
- // The lobby's Durable Object, bound as CHAT_LOBBY: one for the Worker, named `lobby`, keeping
68
- // every room's preview, which each room tells it of and its pages follow.
69
55
  export class ChatLobby extends ActionDurableObject.make({
70
56
  services: () => layerKvPreviews,
71
57
  routes: lobbyRoutes,
72
58
  calls: lobbyCalls,
73
59
  }) {}
74
60
 
75
- // The Worker's own handler, built once, when the module loads, which keeps the lobby's client
76
- // from stranding as `ActionDurableObject.layerStubClient`'s gotchas say: building it does no I/O,
77
- // which a Worker refuses outside a request. `toWebHandler` reads no `MaxBodySize`, so the
78
- // platform limits request bodies.
79
61
  const served = HttpRouter.toWebHandler(
80
62
  workerRoutes.pipe(
81
63
  Layer.provide(layerLobbyHost),
@@ -83,11 +65,8 @@ const served = HttpRouter.toWebHandler(
83
65
  )
84
66
  );
85
67
 
86
- const isRoomName = Schema.is(RoomName);
68
+ const isRoomName = Schema.is(DemoRoomName);
87
69
 
88
- // A page's socket, `/rooms/<room>/rpc` or `/lobby/rpc`, is its object's `/rpc`: forwarding the
89
- // request forwards its upgrade, and the object answers it. Everything else is the Worker's own,
90
- // the rooms' calls among them, which it sends on to each room's object itself.
91
70
  export default {
92
71
  async fetch(request: Request, env: Cloudflare.Env) {
93
72
  const url = new URL(request.url);
@@ -148,9 +127,6 @@ const room = [messages, messageEvents];
148
127
 
149
128
  const byRoom = (input: { readonly room: string }) => input.room;
150
129
 
151
- // Where each room's calls and watches are served: in the room's object, the one each contract's
152
- // key names. worker.ts gives it to the room's class, which then refuses an input naming another
153
- // room, on its socket too; the Worker serves it below.
154
130
  export const rooms = ActionDurableObject.placed(RoomHttp, {
155
131
  readMessages: byRoom,
156
132
  postMessage: byRoom,
@@ -158,48 +134,26 @@ export const rooms = ActionDurableObject.placed(RoomHttp, {
158
134
  "message.posted": byRoom,
159
135
  });
160
136
 
161
- // One socket route for each object's pages, refusing a page of another version.
162
137
  const pageSocket = ActionDurableObject.layerWebSocket({
163
138
  path: "/rpc",
164
139
  protocol: { version: protocolVersion, code: unsupportedProtocol },
165
140
  });
166
141
 
167
- // What a room's object serves. Its rpcs on WebSockets the runtime holds while the object
168
- // hibernates: a socket keeps the token its upgrade request carried, and each message is
169
- // authenticated with it, or with the message's own. The socket's route refuses a page naming
170
- // another version in its URL, `?protocol=<n>` as browser.ts sends it, with a close code the page's
171
- // client reads as `SocketCloseError` and stops connecting again at, not a 403, which a browser
172
- // reports as a bare 1006 its page connects again after. Its JSON routes, where the Worker sends
173
- // each call and watch on, the token with it: the object authenticates it again, and authorizes.
174
- // It requires `MessageStore`, `Room` and `Activity` at startup: worker.ts chooses them.
175
142
  export const roomRoutes = Layer.mergeAll(
176
143
  ActionRpc.layer(RoomRpc, room).pipe(Layer.provide(pageSocket)),
177
144
  ActionHttp.layer(RoomHttp, room)
178
145
  ).pipe(Layer.provide(authenticate));
179
146
 
180
- // What the lobby's object serves: `PreviewsChanged` on its pages' sockets, a socket a page
181
- // signed out opens too, as nothing on it is protected. It requires `PreviewStore` at startup.
182
147
  export const lobbyRoutes = ActionRpc.layer(LobbyRpc, [previewEvents]).pipe(
183
148
  Layer.provide(pageSocket)
184
149
  );
185
150
 
186
- // What the lobby's object serves over its stub: its own calls, on Effect's own RpcServer, for
187
- // the rooms and the Worker, which call them with Cloudflare's own RPC. It requires `PreviewStore`
188
- // at startup.
189
151
  export const lobbyCalls = RpcServer.layer(LobbyCalls).pipe(
190
152
  Layer.provide(lobbyHandlers)
191
153
  );
192
154
 
193
- // The rooms as the Worker serves them, each call sent on to its room's object. The room is read
194
- // from `env` as each call is sent.
195
155
  const inRooms = ActionDurableObject.reaching(rooms, () => env.CHAT_ROOM);
196
156
 
197
- // What the Worker serves: the rooms' JSON routes and `previews`, which it asks the lobby's object
198
- // for, at `/api`, and all of them at `/mcp`, the actions as tools and `MessagePosted` as an MCP
199
- // event, so one agent's connection reaches every room. Nothing in a Worker outlives a request, so
200
- // the endpoint is request scoped: MCP 2026-07-28 alone, and events by `events/poll`, a poll every
201
- // 30 seconds as events.ts asks. The Worker authenticates each call, and the room's object again.
202
- // It requires `LobbyHost`.
203
157
  export const workerRoutes = Layer.mergeAll(
204
158
  ActionHttp.layer(LobbyHttp, lobby),
205
159
  ActionHttp.layer(RoomHttp, inRooms),
package/docs/README.md CHANGED
@@ -9,7 +9,7 @@ These pages cover what this package adds. Everything else, contracts, implementa
9
9
  **Arriving with an error?** A message the library throws says its fix; for any other, step 4.
10
10
 
11
11
  1. **Open one page per tool call, with your file-reading tool.** Pages are kept small to reduce truncation; if an output says it was truncated, read that page again alone.
12
- 2. **Start from [chat-rooms](../examples/chat-rooms/)**, the one example: its README has a layout tree and a "Where to look" table naming the file for each thing, and its comments carry the traps it shows.
12
+ 2. **Start from [chat-rooms](../examples/chat-rooms/)**, the one example: its README has a layout tree and a "Where to look" table naming the file for each thing, and its code and README carry the traps it shows.
13
13
  3. **Read [ActionDurableObject.md](ActionDurableObject.md) completely**, then the page of each task you have ([Pages](#pages)), and the example files their sections link.
14
14
  4. **For workerd's, a compiler diagnostic or Effect's own error, search [ActionDurableObject-failures.md](ActionDurableObject-failures.md)** for a fragment of the message without quotes, then the core's `*-failures.md` pages.
15
15
 
@@ -18,10 +18,10 @@ In a project that installs the package, copy this directory out of `node_modules
18
18
 
19
19
  ## When you copy this
20
20
 
21
- Each trap is a comment in the file named.
21
+ Each trap below is shown in the code of the files it names.
22
22
 
23
23
  - **A page sends its token on each message**, as a browser WebSocket sets no header on its upgrade: `src/browser.ts`; `test/worker.test.ts` connects both ways.
24
- - **The room is the object's name**, and only a room `RoomName` lists is reached: `src/chat/Chat.ts`, `src/worker.ts`.
24
+ - **The room is the object's name**, and only a room `DemoRoomName` lists is reached: `src/chat/Chat.ts`, `src/worker.ts`.
25
25
  - **The Worker serves every room, each room answers**, through the placement `rooms`: `src/routes.ts`.
26
26
  - **A page reads, then watches from the last message it holds**: `src/browser.ts`, `src/chat/events.ts`.
27
27
  - **`postMessage` notifies `MessagePosted`**: `src/chat/handlers.ts`, `src/chat/events.ts`.
@@ -4,11 +4,8 @@ import { MessagePosted, Messages } from "./chat/Chat.js";
4
4
  import { Login } from "./identity/Identity.js";
5
5
  import { Previews } from "./lobby/Lobby.js";
6
6
 
7
- // The rooms' JSON routes, at `/api/<action>` and `/api/events/<name>`: the Worker's, which a
8
- // client without a socket calls, and each room's object's, where the Worker sends each call on.
9
7
  export const RoomHttp = ActionHttp.make([...Messages, MessagePosted], {
10
8
  authentication: Login,
11
9
  });
12
10
 
13
- // The Worker's own JSON routes.
14
11
  export const LobbyHttp = ActionHttp.make([Previews]);
@@ -4,19 +4,12 @@ import { MessagePosted, Messages } from "./chat/Chat.js";
4
4
  import { Login } from "./identity/Identity.js";
5
5
  import { PreviewsChanged } from "./lobby/Lobby.js";
6
6
 
7
- // A room's rpcs and its event, on the socket a page keeps open to its object. Plain data: the
8
- // object and a browser page both import it.
9
7
  export const RoomRpc = ActionRpc.make([...Messages, MessagePosted], {
10
8
  authentication: Login,
11
9
  });
12
10
 
13
- // The lobby's event, on the socket a page keeps open to the lobby's object: public, so a page
14
- // signed out follows it, and refused at another version as a room's socket is.
15
11
  export const LobbyRpc = ActionRpc.make([PreviewsChanged]);
16
12
 
17
- // The version of these rpcs a page was built with, which it names in its socket's URL, and the
18
- // close code an object answers a page naming another with: raise the version when a change would
19
- // break a page still open from before a deployment.
20
13
  export const protocolVersion = "1";
21
14
 
22
15
  export const unsupportedProtocol = 4000;
@@ -5,7 +5,7 @@ import { RpcClient, RpcSerialization } from "effect/rpc";
5
5
  import { Socket } from "effect/socket";
6
6
 
7
7
  import { MessagePosted } from "./chat/Chat.js";
8
- import type { RoomName } from "./chat/Chat.js";
8
+ import type { DemoRoomName } from "./chat/Chat.js";
9
9
  import { PreviewsChanged } from "./lobby/Lobby.js";
10
10
  import {
11
11
  LobbyRpc,
@@ -14,17 +14,6 @@ import {
14
14
  unsupportedProtocol,
15
15
  } from "./Rpc.js";
16
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
17
  export const connection = (url: string) =>
29
18
  RpcClient.layerProtocolSocket({
30
19
  retryPolicy: ActionRpc.reconnectUntil(unsupportedProtocol),
@@ -33,27 +22,18 @@ export const connection = (url: string) =>
33
22
  Layer.provide(RpcSerialization.layerJson)
34
23
  );
35
24
 
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) =>
25
+ export const openRoom = (room: DemoRoomName, token: string) =>
41
26
  Effect.gen(function* () {
42
27
  const client = yield* ActionRpc.client(RoomRpc);
43
28
  const headers = { authorization: `Bearer ${token}` };
44
29
  const signedIn = RpcClient.withHeaders(headers);
45
30
 
46
31
  return {
47
- // The room's messages after `after`, the id of the last one the page holds: 0 reads them
48
- // all.
49
32
  read: (after: number) =>
50
33
  client.readMessages({ room, after }).pipe(
51
34
  signedIn,
52
35
  Effect.map(({ messages }) => messages)
53
36
  ),
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
37
  post: (text: string) =>
58
38
  Effect.flatMap(
59
39
  Effect.sync(() => crypto.randomUUID()),
@@ -66,14 +46,6 @@ export const openRoom = (room: RoomName, token: string) =>
66
46
  })
67
47
  )
68
48
  ),
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
49
  messages: (after: number) =>
78
50
  client
79
51
  .watch(MessagePosted, { room }, { cursor: String(after) })
@@ -95,10 +67,6 @@ export const openRoom = (room: RoomName, token: string) =>
95
67
  };
96
68
  });
97
69
 
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
70
  export const followLobby = Effect.map(ActionRpc.client(LobbyRpc), (client) =>
103
71
  client.watch(PreviewsChanged, {}).pipe(
104
72
  Stream.filter((received) => Predicate.isTagged(received, "Occurrence")),
@@ -4,15 +4,10 @@ import { Schema } from "effect";
4
4
 
5
5
  import { CurrentMember } from "../identity/Identity.js";
6
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"]);
7
+ export const DemoRoomName = Schema.Literals(["general", "design"]);
11
8
 
12
- export type RoomName = typeof RoomName.Type;
9
+ export type DemoRoomName = typeof DemoRoomName.Type;
13
10
 
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
11
  export const Message = Schema.Struct({
17
12
  id: Schema.Int,
18
13
  author: Schema.String,
@@ -22,8 +17,6 @@ export const Message = Schema.Struct({
22
17
 
23
18
  export type Message = typeof Message.Type;
24
19
 
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
20
  export class MessageNotFound extends Schema.TaggedError<
28
21
  MessageNotFound,
29
22
  { readonly brand: unique symbol }
@@ -34,16 +27,10 @@ export class NotAuthor extends Schema.TaggedError<
34
27
  { readonly brand: unique symbol }
35
28
  >()("NotAuthor", { id: Schema.Int }, { httpApiStatus: 403 }) {}
36
29
 
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 };
30
+ const inRoom = { room: DemoRoomName };
42
31
 
43
32
  const signedIn = { caller: CurrentMember } as const;
44
33
 
45
- // Reads once: a page opening a room reads what is there, then watches `MessagePosted` below from
46
- // the last id it read.
47
34
  export const ReadMessages = Action.make("readMessages", {
48
35
  ...signedIn,
49
36
  description: "Read the room's messages after the given id; 0 reads them all.",
@@ -52,9 +39,6 @@ export const ReadMessages = Action.make("readMessages", {
52
39
  readOnly: true,
53
40
  });
54
41
 
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
42
  export const PostMessage = Action.make("postMessage", {
59
43
  ...signedIn,
60
44
  description: "Post a message to the room. It expires a day later.",
@@ -83,10 +67,6 @@ export const DeleteMessage = Action.make("deleteMessage", {
83
67
 
84
68
  export const Messages = [ReadMessages, PostMessage, DeleteMessage] as const;
85
69
 
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
70
  export const MessagePosted = Event.make("message.posted", {
91
71
  description: "A message posted to the room.",
92
72
  input: inRoom,
@@ -1,11 +1,6 @@
1
1
  import { Context } from "effect";
2
2
  import type { Effect } from "effect";
3
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
4
  export class Activity extends Context.Service<
10
5
  Activity,
11
6
  {
@@ -4,9 +4,6 @@ import { Effect } from "effect";
4
4
  import { CurrentMember } from "../identity/Identity.js";
5
5
  import { Room } from "./room.js";
6
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
7
  export const authorize = Effect.gen(function* () {
11
8
  const room = yield* Room;
12
9