@gjermundgaraba/effect-actions-cloudflare 0.21.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 (57) hide show
  1. package/dist/ActionDurableObject.d.mts +7 -1
  2. package/dist/ActionDurableObject.mjs +7 -7
  3. package/dist/alarm.d.mts +15 -6
  4. package/dist/alarm.mjs +42 -39
  5. package/dist/batches.mjs +0 -1
  6. package/dist/calls.mjs +0 -11
  7. package/dist/events.mjs +0 -15
  8. package/dist/frames.mjs +6 -9
  9. package/dist/idempotency.mjs +2 -8
  10. package/dist/placed.mjs +0 -4
  11. package/dist/sockets.d.mts +0 -4
  12. package/dist/sockets.mjs +7 -14
  13. package/dist/stub.d.mts +4 -4
  14. package/dist/stub.mjs +9 -8
  15. package/dist/subscriptions.mjs +10 -22
  16. package/dist/tasks.mjs +3 -8
  17. package/dist/webhooks.mjs +5 -21
  18. package/docs/ActionDurableObject-events.md +0 -20
  19. package/docs/ActionDurableObject-failures.md +1 -1
  20. package/docs/ActionDurableObject-placement.md +1 -4
  21. package/docs/ActionDurableObject-stub.md +3 -31
  22. package/docs/ActionDurableObject-testing.md +0 -5
  23. package/docs/ActionDurableObject.md +4 -50
  24. package/docs/README.md +1 -1
  25. package/examples/chat-rooms/README.md +2 -2
  26. package/examples/chat-rooms/src/Api.ts +0 -3
  27. package/examples/chat-rooms/src/Rpc.ts +0 -7
  28. package/examples/chat-rooms/src/browser.ts +2 -34
  29. package/examples/chat-rooms/src/chat/Chat.ts +3 -23
  30. package/examples/chat-rooms/src/chat/activity.ts +0 -5
  31. package/examples/chat-rooms/src/chat/authorize.ts +0 -3
  32. package/examples/chat-rooms/src/chat/events.ts +0 -12
  33. package/examples/chat-rooms/src/chat/handlers.ts +0 -6
  34. package/examples/chat-rooms/src/chat/room.ts +2 -7
  35. package/examples/chat-rooms/src/chat/store.ts +0 -6
  36. package/examples/chat-rooms/src/identity/Identity.ts +0 -6
  37. package/examples/chat-rooms/src/identity/members.ts +1 -3
  38. package/examples/chat-rooms/src/identity/verifier.ts +6 -9
  39. package/examples/chat-rooms/src/lobby/Calls.ts +0 -5
  40. package/examples/chat-rooms/src/lobby/Lobby.ts +2 -7
  41. package/examples/chat-rooms/src/lobby/events.ts +0 -5
  42. package/examples/chat-rooms/src/lobby/handlers.ts +0 -3
  43. package/examples/chat-rooms/src/lobby/host.ts +0 -1
  44. package/examples/chat-rooms/src/lobby/store.ts +2 -4
  45. package/examples/chat-rooms/src/platform/kv-previews.ts +6 -5
  46. package/examples/chat-rooms/src/platform/lobby-stubs.ts +0 -15
  47. package/examples/chat-rooms/src/platform/sql-store.ts +1 -13
  48. package/examples/chat-rooms/src/rooms-entrypoint.ts +2 -10
  49. package/examples/chat-rooms/src/routes.ts +0 -25
  50. package/examples/chat-rooms/src/worker.ts +3 -24
  51. package/examples/chat-rooms/test/platform/sql-store.test.ts +0 -5
  52. package/examples/chat-rooms/test/support/socket.ts +0 -9
  53. package/examples/chat-rooms/test/worker.test.ts +17 -24
  54. package/examples/chat-rooms/vite.config.ts +0 -5
  55. package/package.json +3 -3
  56. package/examples/chat-rooms/node_modules/.vite/vitest/CACHEDIR.TAG +0 -3
  57. package/examples/chat-rooms/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -2,7 +2,6 @@ import { deleted } from "./batches.mjs";
2
2
  import { RequestId, closeQuietly, groupTag, socketIdOf } from "./frames.mjs";
3
3
  import { Effect, Function, Match, Option, Predicate, Schema } from "effect";
4
4
  // src/durable/subscriptions.ts
5
- /** Where an object keeps its sockets' event subscriptions. */
6
5
  const keptPrefix = "effect-actions/subscription/";
7
6
  const Request = Schema.TaggedStruct("Request", {
8
7
  id: RequestId,
@@ -11,33 +10,27 @@ const Request = Schema.TaggedStruct("Request", {
11
10
  });
12
11
  const requestOf = Schema.decodeUnknownOption(Request);
13
12
  const payloadOf = Schema.decodeUnknownOption(Schema.Json);
13
+ const resumeFailedCode = 1e3;
14
14
  const unresumable = "This server could not resume this socket's subscriptions: connect again";
15
15
  const suffixOf = (socket, id) => `${socket}#${Predicate.isNumber(id) ? "n" : "s"}${id}`;
16
16
  const socketOf = (suffix) => Number(suffix.slice(0, suffix.indexOf("#")));
17
- /**
18
- * The event subscriptions of the sockets one `layerWebSocket` path accepted, kept in storage:
19
- * `keeper` stores each request as `ActionRpc.layer` admits it, `sent` stores the payload that
20
- * resumes it with each chunk sent and forgets it at its exit, `closed` forgets a socket's, and
21
- * `resumed` writes each kept one whose socket of this path is still open to the server again,
22
- * from there.
23
- */
24
17
  const makeSubscriptions = (state, path) => {
25
18
  const { storage } = state;
26
19
  const group = groupTag(path);
27
20
  const requests = `${keptPrefix}request/${path}#`;
28
21
  const payloads = `${keptPrefix}payload/${path}#`;
29
22
  const keysOf = (suffix) => [`${requests}${suffix}`, `${payloads}${suffix}`];
30
- const live = /* @__PURE__ */ new Map();
23
+ const liveSubscriptionsMirror = /* @__PURE__ */ new Map();
31
24
  const listed = (prefix) => Effect.map(Effect.promise(async () => await storage.list({ prefix })), (found) => [...found].map(([key, value]) => [key.slice(prefix.length), value]));
32
25
  const closed = (socket) => Effect.suspend(() => {
33
- const owned = [...live.keys()].filter((suffix) => socketOf(suffix) === socket);
34
- for (const suffix of owned) live.delete(suffix);
26
+ const owned = [...liveSubscriptionsMirror.keys()].filter((suffix) => socketOf(suffix) === socket);
27
+ for (const suffix of owned) liveSubscriptionsMirror.delete(suffix);
35
28
  return deleted(storage, owned.flatMap(keysOf));
36
29
  });
37
30
  const keeper = { keep: ({ clientId, requestId, tag, payload, headers, resumeFrom }) => Effect.suspend(() => {
38
31
  const suffix = suffixOf(clientId, requestId);
39
- const resuming = live.has(suffix);
40
- live.set(suffix, resumeFrom);
32
+ const resuming = liveSubscriptionsMirror.has(suffix);
33
+ liveSubscriptionsMirror.set(suffix, resumeFrom);
41
34
  if (resuming) return Effect.void;
42
35
  return Effect.promise(async () => {
43
36
  await storage.put({
@@ -51,23 +44,18 @@ const makeSubscriptions = (state, path) => {
51
44
  });
52
45
  }) };
53
46
  const moved = (suffix, values) => Effect.suspend(() => {
54
- const payload = live.get(suffix)?.(values);
47
+ const payload = liveSubscriptionsMirror.get(suffix)?.(values);
55
48
  return payload === void 0 ? Effect.void : Effect.promise(async () => {
56
49
  await storage.put(`${payloads}${suffix}`, payload);
57
50
  });
58
51
  });
59
52
  const sent = (socket, response) => Match.value(response).pipe(Match.tag("Defect", () => closed(socket)), Match.tag("Chunk", ({ requestId, values }) => moved(suffixOf(socket, requestId), values)), Match.tag("Exit", ({ requestId }) => Effect.suspend(() => {
60
53
  const suffix = suffixOf(socket, requestId);
61
- return live.delete(suffix) ? Effect.promise(async () => await storage.delete(keysOf(suffix))) : Effect.void;
54
+ return liveSubscriptionsMirror.delete(suffix) ? Effect.promise(async () => await storage.delete(keysOf(suffix))) : Effect.void;
62
55
  })), Match.orElse(() => Effect.void));
63
56
  const unresumed = (socket) => Effect.andThen(Effect.logWarning(`layerWebSocket at ${path} could not resume a socket's kept event subscriptions, and closes it`), Effect.sync(() => {
64
- closeQuietly(socket, 1e3, unresumable);
57
+ closeQuietly(socket, resumeFailedCode, unresumable);
65
58
  }));
66
- /**
67
- * Writes each kept subscription of a socket of this path still open to the server, from where
68
- * it reached, and forgets the rest: `held` answers whether a socket stays open, given whether
69
- * one of its requests is a subscription kept for it, and closes one that does not.
70
- */
71
59
  const resumed = (write, held) => Effect.gen(function* () {
72
60
  const kept = yield* listed(requests);
73
61
  const resumeFrom = new Map(yield* listed(payloads));
@@ -88,7 +76,7 @@ const makeSubscriptions = (state, path) => {
88
76
  const unread = new Set(entries.flatMap(({ socket, request }) => open.has(socket) && Option.isNone(request) ? [socket] : []));
89
77
  const gone = [...resumeFrom.keys()].flatMap((suffix) => keptIds.has(suffix) ? [] : [`${payloads}${suffix}`]);
90
78
  for (const { suffix, socket, request } of entries) if (open.has(socket) && !unread.has(socket) && Option.isSome(request)) {
91
- live.set(suffix, Function.constUndefined);
79
+ liveSubscriptionsMirror.set(suffix, Function.constUndefined);
92
80
  yield* write(socket, request.value);
93
81
  } else gone.push(...keysOf(suffix));
94
82
  yield* deleted(storage, gone);
package/dist/tasks.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Clock, Context, Effect } from "effect";
2
2
  // src/durable/tasks.ts
3
- /** The alarm owner a task's time is kept under, one per task name. */
4
3
  const taskOwner = (name) => `task:${name}`;
5
4
  /**
6
5
  * Runs the object's tasks, `make`'s or `handlers`' `tasks`, by name, after a change, on the
@@ -15,16 +14,12 @@ const taskOwner = (name) => `task:${name}`;
15
14
  * - A run that fails is logged and retried as the alarm retries an owner: 2 seconds later, doubling, six times, then hourly.
16
15
  */
17
16
  var Tasks = class extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Tasks") {};
18
- /**
19
- * The instance's `Tasks` over its `schedule`, and the alarm owners that run each of `tasks`, by
20
- * the name `runAfter` takes.
21
- */
22
17
  const makeTasks = (schedule, tasks) => ({
23
18
  tasks: Tasks.of({ runAfter: (name, change) => Effect.gen(function* () {
24
19
  if (!Object.hasOwn(tasks, name)) return yield* Effect.die(/* @__PURE__ */ new Error(`ActionDurableObject.Tasks: this object has no task ${name} (give make or handlers a tasks entry named ${name})`));
25
- const marked = schedule.set(taskOwner(name), yield* Clock.currentTimeMillis);
26
- yield* marked;
27
- return yield* Effect.ensuring(change, marked);
20
+ const markDueNow = schedule.set(taskOwner(name), yield* Clock.currentTimeMillis);
21
+ yield* markDueNow;
22
+ return yield* Effect.ensuring(change, markDueNow);
28
23
  }) }),
29
24
  owners: Object.fromEntries(Object.entries(tasks).map(([name, task]) => [taskOwner(name), () => task]))
30
25
  });
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),
@@ -224,7 +178,7 @@ Give each of a binding's contracts a key of its input, `rooms` in `routes.ts` ab
224
178
 
225
179
  ## Run an alarm
226
180
 
227
- Give `make` an `alarm`, and set when it runs from a handler or a store with the `Alarm` service: the room's store sets it for the oldest message's expiry and, each run, for the next ([chat-rooms/src/platform/sql-store.ts](../examples/chat-rooms/src/platform/sql-store.ts)), and `worker.ts` above gives the alarm the store the routes read. How it runs, and how a failure is retried, are on `ActionDurableObject.Options`' `alarm`; why `State`'s `storage.setAlarm` is not the way, on `ActionDurableObject.Alarm`.
181
+ Give `make` an `alarm`, and set when it runs from a handler or a store with the `Alarm` service, which keeps the earliest time it is given: the room's store sets it for each message's expiry as it is posted and, each run, for the oldest left ([chat-rooms/src/platform/sql-store.ts](../examples/chat-rooms/src/platform/sql-store.ts)), and `worker.ts` above gives the alarm the store the routes read. How it runs, and how a failure is retried, are on `ActionDurableObject.Options`' `alarm`; how to postpone it, and why `State`'s `storage.setAlarm` is not the way, on `ActionDurableObject.Alarm`.
228
182
 
229
183
  ## Call an object from a Worker
230
184
 
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;