@gjermundgaraba/effect-actions-cloudflare 0.0.0-stage → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -2
  3. package/dist/ActionDurableObject.d.mts +240 -0
  4. package/dist/ActionDurableObject.mjs +190 -0
  5. package/dist/alarm.d.mts +33 -0
  6. package/dist/alarm.mjs +130 -0
  7. package/dist/batches.mjs +10 -0
  8. package/dist/calls.d.mts +9 -0
  9. package/dist/calls.mjs +92 -0
  10. package/dist/events.mjs +131 -0
  11. package/dist/fetcher.d.mts +17 -0
  12. package/dist/fetcher.mjs +19 -0
  13. package/dist/frames.mjs +33 -0
  14. package/dist/idempotency.mjs +40 -0
  15. package/dist/placed.d.mts +47 -0
  16. package/dist/placed.mjs +53 -0
  17. package/dist/sockets.d.mts +115 -0
  18. package/dist/sockets.mjs +243 -0
  19. package/dist/state.d.mts +18 -0
  20. package/dist/state.mjs +18 -0
  21. package/dist/stub.d.mts +59 -0
  22. package/dist/stub.mjs +75 -0
  23. package/dist/subscriptions.mjs +104 -0
  24. package/dist/tasks.d.mts +24 -0
  25. package/dist/tasks.mjs +31 -0
  26. package/dist/webhooks.d.mts +36 -0
  27. package/dist/webhooks.mjs +108 -0
  28. package/docs/ActionDurableObject-events.md +153 -0
  29. package/docs/ActionDurableObject-failures.md +18 -0
  30. package/docs/ActionDurableObject-own-class.md +9 -0
  31. package/docs/ActionDurableObject-placement.md +41 -0
  32. package/docs/ActionDurableObject-stub.md +135 -0
  33. package/docs/ActionDurableObject-testing.md +35 -0
  34. package/docs/ActionDurableObject.md +235 -0
  35. package/docs/README.md +28 -0
  36. package/examples/chat-rooms/README.md +97 -0
  37. package/examples/chat-rooms/node_modules/.vite/vitest/CACHEDIR.TAG +3 -0
  38. package/examples/chat-rooms/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  39. package/examples/chat-rooms/src/Api.ts +14 -0
  40. package/examples/chat-rooms/src/Rpc.ts +22 -0
  41. package/examples/chat-rooms/src/browser.ts +107 -0
  42. package/examples/chat-rooms/src/chat/Chat.ts +95 -0
  43. package/examples/chat-rooms/src/chat/activity.ts +16 -0
  44. package/examples/chat-rooms/src/chat/authorize.ts +22 -0
  45. package/examples/chat-rooms/src/chat/events.ts +64 -0
  46. package/examples/chat-rooms/src/chat/handlers.ts +38 -0
  47. package/examples/chat-rooms/src/chat/room.ts +12 -0
  48. package/examples/chat-rooms/src/chat/store.ts +25 -0
  49. package/examples/chat-rooms/src/env.d.ts +12 -0
  50. package/examples/chat-rooms/src/identity/Identity.ts +21 -0
  51. package/examples/chat-rooms/src/identity/members.ts +8 -0
  52. package/examples/chat-rooms/src/identity/verifier.ts +24 -0
  53. package/examples/chat-rooms/src/lobby/Calls.ts +14 -0
  54. package/examples/chat-rooms/src/lobby/Lobby.ts +32 -0
  55. package/examples/chat-rooms/src/lobby/events.ts +17 -0
  56. package/examples/chat-rooms/src/lobby/handlers.ts +34 -0
  57. package/examples/chat-rooms/src/lobby/host.ts +10 -0
  58. package/examples/chat-rooms/src/lobby/store.ts +18 -0
  59. package/examples/chat-rooms/src/platform/kv-previews.ts +35 -0
  60. package/examples/chat-rooms/src/platform/lobby-stubs.ts +46 -0
  61. package/examples/chat-rooms/src/platform/sql-store.ts +111 -0
  62. package/examples/chat-rooms/src/rooms-entrypoint.ts +33 -0
  63. package/examples/chat-rooms/src/routes.ts +89 -0
  64. package/examples/chat-rooms/src/worker.ts +99 -0
  65. package/examples/chat-rooms/test/platform/sql-store.test.ts +132 -0
  66. package/examples/chat-rooms/test/support/socket.ts +70 -0
  67. package/examples/chat-rooms/test/worker.test.ts +572 -0
  68. package/examples/chat-rooms/tsconfig.json +22 -0
  69. package/examples/chat-rooms/vite.config.ts +19 -0
  70. package/examples/chat-rooms/wrangler.jsonc +15 -0
  71. package/package.json +49 -3
@@ -0,0 +1,104 @@
1
+ import { deleted } from "./batches.mjs";
2
+ import { RequestId, closeQuietly, groupTag, socketIdOf } from "./frames.mjs";
3
+ import { Effect, Function, Match, Option, Predicate, Schema } from "effect";
4
+ // src/durable/subscriptions.ts
5
+ /** Where an object keeps its sockets' event subscriptions. */
6
+ const keptPrefix = "effect-actions/subscription/";
7
+ const Request = Schema.TaggedStruct("Request", {
8
+ id: RequestId,
9
+ tag: Schema.String,
10
+ headers: Schema.mutable(Schema.Array(Schema.mutable(Schema.Tuple([Schema.String, Schema.String]))))
11
+ });
12
+ const requestOf = Schema.decodeUnknownOption(Request);
13
+ const payloadOf = Schema.decodeUnknownOption(Schema.Json);
14
+ const unresumable = "This server could not resume this socket's subscriptions: connect again";
15
+ const suffixOf = (socket, id) => `${socket}#${Predicate.isNumber(id) ? "n" : "s"}${id}`;
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
+ const makeSubscriptions = (state, path) => {
25
+ const { storage } = state;
26
+ const group = groupTag(path);
27
+ const requests = `${keptPrefix}request/${path}#`;
28
+ const payloads = `${keptPrefix}payload/${path}#`;
29
+ const keysOf = (suffix) => [`${requests}${suffix}`, `${payloads}${suffix}`];
30
+ const live = /* @__PURE__ */ new Map();
31
+ const listed = (prefix) => Effect.map(Effect.promise(async () => await storage.list({ prefix })), (found) => [...found].map(([key, value]) => [key.slice(prefix.length), value]));
32
+ const closed = (socket) => Effect.suspend(() => {
33
+ const owned = [...live.keys()].filter((suffix) => socketOf(suffix) === socket);
34
+ for (const suffix of owned) live.delete(suffix);
35
+ return deleted(storage, owned.flatMap(keysOf));
36
+ });
37
+ const keeper = { keep: ({ clientId, requestId, tag, payload, headers, resumeFrom }) => Effect.suspend(() => {
38
+ const suffix = suffixOf(clientId, requestId);
39
+ const resuming = live.has(suffix);
40
+ live.set(suffix, resumeFrom);
41
+ if (resuming) return Effect.void;
42
+ return Effect.promise(async () => {
43
+ await storage.put({
44
+ [`${requests}${suffix}`]: Request.make({
45
+ id: requestId,
46
+ tag,
47
+ headers: headers.map(([name, value]) => [name, value])
48
+ }),
49
+ [`${payloads}${suffix}`]: payload
50
+ });
51
+ });
52
+ }) };
53
+ const moved = (suffix, values) => Effect.suspend(() => {
54
+ const payload = live.get(suffix)?.(values);
55
+ return payload === void 0 ? Effect.void : Effect.promise(async () => {
56
+ await storage.put(`${payloads}${suffix}`, payload);
57
+ });
58
+ });
59
+ 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
+ const suffix = suffixOf(socket, requestId);
61
+ return live.delete(suffix) ? Effect.promise(async () => await storage.delete(keysOf(suffix))) : Effect.void;
62
+ })), Match.orElse(() => Effect.void));
63
+ 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);
65
+ }));
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
+ const resumed = (write, held) => Effect.gen(function* () {
72
+ const kept = yield* listed(requests);
73
+ const resumeFrom = new Map(yield* listed(payloads));
74
+ const keptIds = new Set(kept.map(([suffix]) => suffix));
75
+ const open = /* @__PURE__ */ new Map();
76
+ for (const socket of state.getWebSockets(group)) {
77
+ const id = socketIdOf(state.getTags(socket));
78
+ if (held(socket, (requestId) => keptIds.has(suffixOf(id, requestId)))) open.set(id, socket);
79
+ }
80
+ const entries = kept.map(([suffix, value]) => ({
81
+ suffix,
82
+ socket: socketOf(suffix),
83
+ request: Option.zipWith(requestOf(value), payloadOf(resumeFrom.get(suffix)), (request, payload) => ({
84
+ ...request,
85
+ payload
86
+ }))
87
+ }));
88
+ const unread = new Set(entries.flatMap(({ socket, request }) => open.has(socket) && Option.isNone(request) ? [socket] : []));
89
+ const gone = [...resumeFrom.keys()].flatMap((suffix) => keptIds.has(suffix) ? [] : [`${payloads}${suffix}`]);
90
+ for (const { suffix, socket, request } of entries) if (open.has(socket) && !unread.has(socket) && Option.isSome(request)) {
91
+ live.set(suffix, Function.constUndefined);
92
+ yield* write(socket, request.value);
93
+ } else gone.push(...keysOf(suffix));
94
+ yield* deleted(storage, gone);
95
+ for (const [id, socket] of open) if (unread.has(id)) yield* unresumed(socket);
96
+ });
97
+ return {
98
+ keeper,
99
+ sent,
100
+ closed,
101
+ resumed
102
+ };
103
+ };
104
+ export { keptPrefix, makeSubscriptions };
@@ -0,0 +1,24 @@
1
+ import "./alarm.mjs";
2
+ import { Context, Effect } from "effect";
3
+ declare const Tasks_base: Context.ServiceClass<Tasks, "effect-actions-cloudflare/ActionDurableObject/Tasks", {
4
+ /**
5
+ * Marks the task `name` due now in the object's storage, runs `change`, and marks it again
6
+ * however `change` ended: the object's alarm, set for now, runs it after the call, and a
7
+ * crash at any point leaves the mark, so the alarm still runs it.
8
+ */
9
+ readonly runAfter: <A, E, R>(name: string, change: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
10
+ }>;
11
+ /**
12
+ * Runs the object's tasks, `make`'s or `handlers`' `tasks`, by name, after a change, on the
13
+ * object's alarm until it succeeds, so the work it does, such as telling another object of the
14
+ * change, is done at least once. A startup service, as `Alarm` is.
15
+ *
16
+ * **Gotchas**
17
+ *
18
+ * - A task runs on the alarm after the call that changed it has answered: a caller reading the object it tells at once may find it not yet told.
19
+ * - A task reads what it sends as it runs, such as the state it announces: one run covers every change before it, so it may repeat, and the object it calls ignores what it already holds.
20
+ * - The alarm may run a task as a change waits, on another object say, over part of that change: the change marks it again as it ends, so a run over the whole change follows.
21
+ * - A run that fails is logged and retried as the alarm retries an owner: 2 seconds later, doubling, six times, then hourly.
22
+ */
23
+ export declare class Tasks extends Tasks_base {}
24
+ export {};
package/dist/tasks.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import { Clock, Context, Effect } from "effect";
2
+ // src/durable/tasks.ts
3
+ /** The alarm owner a task's time is kept under, one per task name. */
4
+ const taskOwner = (name) => `task:${name}`;
5
+ /**
6
+ * Runs the object's tasks, `make`'s or `handlers`' `tasks`, by name, after a change, on the
7
+ * object's alarm until it succeeds, so the work it does, such as telling another object of the
8
+ * change, is done at least once. A startup service, as `Alarm` is.
9
+ *
10
+ * **Gotchas**
11
+ *
12
+ * - A task runs on the alarm after the call that changed it has answered: a caller reading the object it tells at once may find it not yet told.
13
+ * - A task reads what it sends as it runs, such as the state it announces: one run covers every change before it, so it may repeat, and the object it calls ignores what it already holds.
14
+ * - The alarm may run a task as a change waits, on another object say, over part of that change: the change marks it again as it ends, so a run over the whole change follows.
15
+ * - A run that fails is logged and retried as the alarm retries an owner: 2 seconds later, doubling, six times, then hourly.
16
+ */
17
+ 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
+ const makeTasks = (schedule, tasks) => ({
23
+ tasks: Tasks.of({ runAfter: (name, change) => Effect.gen(function* () {
24
+ 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);
28
+ }) }),
29
+ owners: Object.fromEntries(Object.entries(tasks).map(([name, task]) => [taskOwner(name), () => task]))
30
+ });
31
+ export { Tasks, makeTasks, taskOwner };
@@ -0,0 +1,36 @@
1
+ import { State } from "./state.mjs";
2
+ import "./alarm.mjs";
3
+ import { Context, Effect, Layer } from "effect";
4
+ import { HttpClient } from "effect/http";
5
+ import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
6
+ type Webhooks = Layer.Success<ReturnType<typeof Webhook.layer>>;
7
+ declare const Waits_base: Context.ServiceClass<Waits, "effect-actions-cloudflare/ActionDurableObject/Waits", {
8
+ readonly until: (at: number) => Effect.Effect<void>;
9
+ }>;
10
+ /**
11
+ * The instance's long waits, which the object's alarm ends: `until(at)` waits until the alarm
12
+ * runs at or after `at`, holding no timer, so the object may hibernate meanwhile. `handlers` and
13
+ * `make` provide it to the routes, over the instance's schedule, for `layerWebhooks`.
14
+ */
15
+ export declare class Waits extends Waits_base {}
16
+ /** How `layerWebhooks` grants, verifies and retries: `Webhook.Options` but what it fixes. */
17
+ export type LayerWebhooksOptions = Omit<Webhook.Options, "persist" | "resolveCallbacks">;
18
+ /**
19
+ * The core's `Webhook.layer` in an object: each subscription kept in the object's storage, under
20
+ * `effect-actions/webhooks/`, restored as the object builds after an eviction, and each wait of 10
21
+ * seconds or more, a retry's backoff or a subscription's expiry, ended by the object's alarm, so
22
+ * the object hibernates between deliveries and wakes to retry one. Provide it to the routes
23
+ * beside the surfaces offering webhooks, with an `HttpClient` for the deliveries; its `Waits` are
24
+ * the instance's, which `handlers` and `make` provide.
25
+ *
26
+ * **Gotchas**
27
+ *
28
+ * - A callback's name is not resolved for the address check, as Workers have no resolver: a literal address is checked, and a name is left to Cloudflare's egress, which reaches no private network of yours without a Tunnel or a VPC binding of your own.
29
+ * - A delivery in flight when the instance is evicted is sent again by the next, from the subscription's last kept position: a subscriber drops the repeat by its `eventId`.
30
+ * - A protected event offered by webhook needs its authentication's `schema`, as `Webhook.Options`' `persist` says: the subscriber's identity is kept by it, not authenticated again at a wake.
31
+ * - The subscriptions live beside the application's data: a store deleting every key deletes them too, and a subscriber hears nothing more.
32
+ * - Call it once per object and provide that Layer to every surface offering webhooks: two calls are two layers over one store, each losing the other's subscriptions at an eviction.
33
+ * - The source, authorizer and client a delivery runs share its Clock: a wait of 10 seconds or more in them, a timeout included, waits on the object's alarm, holding no timer.
34
+ */
35
+ export declare const layerWebhooks: (options?: LayerWebhooksOptions) => Layer.Layer<Webhooks, never, HttpClient.HttpClient | State | Waits>;
36
+ export {};
@@ -0,0 +1,108 @@
1
+ import { State } from "./state.mjs";
2
+ import { batches } from "./batches.mjs";
3
+ import { Clock, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema, Semaphore } from "effect";
4
+ import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
5
+ import { KeyValueStore } from "effect/persistence";
6
+ // src/durable/webhooks.ts
7
+ /** The owner of the object's alarm times that end its long waits. */
8
+ 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
+ const webhookPrefix = "effect-actions/webhooks/";
14
+ /**
15
+ * The instance's long waits, which the object's alarm ends: `until(at)` waits until the alarm
16
+ * runs at or after `at`, holding no timer, so the object may hibernate meanwhile. `handlers` and
17
+ * `make` provide it to the routes, over the instance's schedule, for `layerWebhooks`.
18
+ */
19
+ 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
+ const makeWaits = (schedule) => {
25
+ 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)))));
27
+ return {
28
+ waits: Waits.of({ until: (at) => Effect.suspend(() => {
29
+ const wait = {
30
+ at,
31
+ woken: Deferred.makeUnsafe()
32
+ };
33
+ held.add(wait);
34
+ return Effect.andThen(armed, Deferred.await(wait.woken)).pipe(Effect.ensuring(Effect.suspend(() => held.delete(wait) ? armed : Effect.void)));
35
+ }) }),
36
+ /** Ends each wait that is due, and sets the alarm for the next. */
37
+ wakeDue: Effect.gen(function* () {
38
+ const now = yield* Clock.currentTimeMillis;
39
+ for (const wait of held) if (wait.at <= now) {
40
+ held.delete(wait);
41
+ Deferred.doneUnsafe(wait.woken, Exit.void);
42
+ }
43
+ yield* armed;
44
+ })
45
+ };
46
+ };
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
+ */
53
+ const alarmClock = (base, { until }) => ({
54
+ currentTimeMillisUnsafe: () => base.currentTimeMillisUnsafe(),
55
+ currentTimeMillis: base.currentTimeMillis,
56
+ currentTimeNanosUnsafe: () => base.currentTimeNanosUnsafe(),
57
+ currentTimeNanos: base.currentTimeNanos,
58
+ monotonicTimeNanosUnsafe: () => base.monotonicTimeNanosUnsafe(),
59
+ monotonicTimeNanos: base.monotonicTimeNanos,
60
+ sleep: (duration) => Duration.isLessThan(duration, timed) || !Duration.isFinite(duration) ? base.sleep(duration) : until(base.currentTimeMillisUnsafe() + Duration.toMillis(duration))
61
+ });
62
+ const stringOf = Schema.decodeUnknownOption(Schema.String);
63
+ const attempted = (method, key, run) => Effect.tryPromise({
64
+ try: run,
65
+ catch: (cause) => new KeyValueStore.KeyValueStoreError({
66
+ message: `The object's storage failed to ${method}`,
67
+ method,
68
+ ...key === void 0 ? {} : { key },
69
+ cause
70
+ })
71
+ });
72
+ const keyOf = (key) => `${webhookPrefix}${key}`;
73
+ /** A `KeyValueStore` over the object's storage under `webhookPrefix`, every key of it there. */
74
+ const storageStore = (storage) => {
75
+ const listed = attempted("list", void 0, async () => await storage.list({ prefix: webhookPrefix }));
76
+ return KeyValueStore.makeStringOnly({
77
+ get: (key) => Effect.map(attempted("get", key, async () => await storage.get(keyOf(key))), (value) => Option.getOrUndefined(stringOf(value))),
78
+ set: (key, value) => attempted("set", key, async () => {
79
+ await storage.put(keyOf(key), value);
80
+ }),
81
+ remove: (key) => Effect.asVoid(attempted("remove", key, async () => await storage.delete(keyOf(key)))),
82
+ clear: Effect.flatMap(listed, (found) => Effect.forEach(batches([...found.keys()]), (keys) => attempted("clear", void 0, async () => await storage.delete([...keys])), { discard: true })),
83
+ size: Effect.map(listed, (found) => found.size)
84
+ });
85
+ };
86
+ /**
87
+ * The core's `Webhook.layer` in an object: each subscription kept in the object's storage, under
88
+ * `effect-actions/webhooks/`, restored as the object builds after an eviction, and each wait of 10
89
+ * seconds or more, a retry's backoff or a subscription's expiry, ended by the object's alarm, so
90
+ * the object hibernates between deliveries and wakes to retry one. Provide it to the routes
91
+ * beside the surfaces offering webhooks, with an `HttpClient` for the deliveries; its `Waits` are
92
+ * the instance's, which `handlers` and `make` provide.
93
+ *
94
+ * **Gotchas**
95
+ *
96
+ * - A callback's name is not resolved for the address check, as Workers have no resolver: a literal address is checked, and a name is left to Cloudflare's egress, which reaches no private network of yours without a Tunnel or a VPC binding of your own.
97
+ * - A delivery in flight when the instance is evicted is sent again by the next, from the subscription's last kept position: a subscriber drops the repeat by its `eventId`.
98
+ * - A protected event offered by webhook needs its authentication's `schema`, as `Webhook.Options`' `persist` says: the subscriber's identity is kept by it, not authenticated again at a wake.
99
+ * - The subscriptions live beside the application's data: a store deleting every key deletes them too, and a subscriber hears nothing more.
100
+ * - Call it once per object and provide that Layer to every surface offering webhooks: two calls are two layers over one store, each losing the other's subscriptions at an eviction.
101
+ * - The source, authorizer and client a delivery runs share its Clock: a wait of 10 seconds or more in them, a timeout included, waits on the object's alarm, holding no timer.
102
+ */
103
+ const layerWebhooks = (options = {}) => Webhook.layer({
104
+ ...options,
105
+ persist: true,
106
+ resolveCallbacks: false
107
+ }).pipe(Layer.provide([Layer.effect(KeyValueStore.KeyValueStore)(Effect.map(State, ({ storage }) => storageStore(storage))), Layer.effect(Clock.Clock)(Effect.map(Effect.all([Clock.Clock, Waits]), ([base, waits]) => alarmClock(base, waits)))]));
108
+ export { Waits, clockOwner, layerWebhooks, makeWaits, webhookPrefix };
@@ -0,0 +1,153 @@
1
+ # ActionDurableObject: events
2
+
3
+ The core's events, the `Event` module, in a Durable Object: a page follows one over its socket, an agent over MCP. Read [ActionDurableObject.md](ActionDurableObject.md) first, and the core's `docs/Event.md` for check, current and log sources, `notify` and `publish`.
4
+
5
+ ## Follow an event over a socket
6
+
7
+ List the event type in the rpcs' binding and its implementation beside the actions' in `ActionRpc.layer`, as `routes.ts` on [ActionDurableObject.md](ActionDurableObject.md) does, and a page watches it on its socket. `make` and `handlers` provide the object's own `Event.Events`, its logs kept in the object's storage, in place of the core's `Event.layerMemory`, so a handler announces with `notify` or `publish` as it does elsewhere. The room's messages are a check source over its store, which `postMessage` notifies after each post:
8
+
9
+ ```ts example=chat-rooms/src/chat/events.ts
10
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
11
+ import { Effect, Option, Schema } from "effect";
12
+
13
+ import { authorize } from "./authorize.js";
14
+ import { MessagePosted } from "./Chat.js";
15
+ import { MessageStore } from "./store.js";
16
+
17
+ // A cursor is the id of the last message a page holds, as `readMessages`' `after` is.
18
+ const idOf = Schema.decodeUnknownOption(
19
+ Schema.NumberFromString.pipe(Schema.check(Schema.isInt()))
20
+ );
21
+
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
+ const nextPoll = "30 seconds";
26
+
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
+ export const messageEvents = Event.implement(
31
+ MessagePosted,
32
+ Effect.gen(function* () {
33
+ const store = yield* MessageStore;
34
+
35
+ return {
36
+ check: (_input, cursor) =>
37
+ Effect.gen(function* () {
38
+ const last = yield* store.last;
39
+
40
+ const after = Option.filter(
41
+ idOf(cursor),
42
+ (id) => id >= 0 && id <= last
43
+ );
44
+
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
+ if (Option.isNone(after)) {
51
+ return {
52
+ cursor: String(last),
53
+ events: [],
54
+ truncated: cursor !== null,
55
+ nextPoll,
56
+ };
57
+ }
58
+
59
+ const messages = yield* store.read(after.value);
60
+
61
+ return {
62
+ cursor: String(messages.at(-1)?.id ?? after.value),
63
+ events: messages.map((data) => ({
64
+ eventId: String(data.id),
65
+ data,
66
+ })),
67
+ nextPoll,
68
+ };
69
+ }),
70
+ };
71
+ }),
72
+ { authorize }
73
+ );
74
+ ```
75
+
76
+ The page reads, then watches from the last id it read: `messages` in [chat-rooms/src/browser.ts](../examples/chat-rooms/src/browser.ts). The object keeps each socket's subscription in its storage while it hibernates and resumes it from the position it reached when it next wakes, on any event, authenticating it again from the headers it was made with: a token that expired meanwhile ends the watch with `Unauthenticated`. What a check, current and log source do in an object is on `ActionDurableObject.handlers`; which calls the object keeps across hibernation, on `ActionDurableObject.layerWebSocket`. To empty an object, `ctx.storage.deleteAll()` deletes its subscriptions, webhook subscriptions and alarm times with the rest: close its sockets as it does, so their pages connect again and watch anew.
77
+
78
+ ## Announce a change another object makes
79
+
80
+ An object's `Event.Events` is its own: `notify` and `publish` reach the subscribers of the object they run in, and no other, so every surface serving an event, a socket's rpcs, MCP or an HTTP watch, is among that object's `routes`, and a Worker's own routes serve actions alone. A change another object makes reaches them through a call, a plain `RpcGroup` on this object's stub, whose handler notifies. chat-rooms' lobby is one object, which pages follow for every room's preview: a room's `lobby` task sends the lobby's `roomChanged` the room's latest activity after each post, deletion and expiry ([chat-rooms/src/platform/lobby-stubs.ts](../examples/chat-rooms/src/platform/lobby-stubs.ts)), and the lobby records it and notifies:
81
+
82
+ ```ts example=chat-rooms/src/lobby/handlers.ts
83
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
84
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
85
+ import { Effect } from "effect";
86
+
87
+ import { LobbyCalls } from "./Calls.js";
88
+ import { LobbyHost } from "./host.js";
89
+ import { Previews, PreviewsChanged } from "./Lobby.js";
90
+ import { PreviewStore } from "./store.js";
91
+
92
+ // The Worker's `previews`, as the lobby's object keeps them.
93
+ export const lobby = Action.implement(
94
+ Previews,
95
+ Effect.map(
96
+ LobbyHost,
97
+ (host) => () => Effect.map(host.previews, (previews) => ({ previews }))
98
+ )
99
+ );
100
+
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
+ export const calls = LobbyCalls.toLayer(
104
+ Effect.gen(function* () {
105
+ const store = yield* PreviewStore;
106
+ const events = yield* Event.Events;
107
+
108
+ return LobbyCalls.of({
109
+ roomChanged: ({ room, latestAt }) =>
110
+ Effect.flatMap(store.record(room, latestAt), (changed) =>
111
+ changed ? events.notify(PreviewsChanged) : Effect.void
112
+ ),
113
+ previews: () => store.read,
114
+ });
115
+ })
116
+ );
117
+ ```
118
+
119
+ The lobby's event is a whole value, every room's preview in each occurrence, so it has a current source, whose revisions the object keeps in its storage: a page is sent the previews as they are, then again after each notify, and watches with no read before it, `followLobby` in [chat-rooms/src/browser.ts](../examples/chat-rooms/src/browser.ts):
120
+
121
+ ```ts example=chat-rooms/src/lobby/events.ts
122
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
123
+ import { Effect } from "effect";
124
+
125
+ import { PreviewsChanged } from "./Lobby.js";
126
+ import { PreviewStore } from "./store.js";
127
+
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
+ export const previewEvents = Event.implement(
134
+ PreviewsChanged,
135
+ Effect.map(PreviewStore, (store) => ({
136
+ current: () => Effect.map(store.read, (previews) => ({ previews })),
137
+ }))
138
+ );
139
+ ```
140
+
141
+ ## Serve the actions and events over MCP
142
+
143
+ An object can serve `ActionMcp.layerHttp` among its routes, but an agent then needs one connection per object. chat-rooms serves its MCP endpoint from the Worker instead, every room's actions and `MessagePosted` reaching the room the input names: [ActionDurableObject-placement.md](ActionDurableObject-placement.md). The core's `docs/ActionMcp.md` says what each method answers; in an object:
144
+
145
+ - A held request keeps the object in memory, billed for its duration, until it ends: an `events/stream`, and a `subscriptions/listen`, the stream an MCP 2026-07-28 client holds open for list changes and resource updates, which `layerHttp` allows wherever it serves events. Its `allowSubscriptions: false` serves events by poll alone.
146
+ - An agent polling with `events/poll` lets the object hibernate only if it asks less often than the runtime's idle window, 10 seconds: a check source answers `nextPoll`, 30 seconds in `events.ts` above, and the agent reads on from its cursor on whichever instance answers.
147
+ - A poll reads the source each time; a stream reads it as `ActionDurableObject.handlers` says.
148
+ - A client of a 2025 revision opens a session, which lives in the instance: once it is evicted, the session's next request is answered 404, at which the MCP specification has the client open a new one. Events are the 2026-07-28 revision's alone, which keeps no session.
149
+ - A tool call is answered once its result is ready, so a reset of the object before then fails the request itself, which the client sees. A tool reporting progress is answered by a stream that opens with its first progress: a reset cuts the stream off with no answer, which a client may take for a call still running, so give such a client a timeout.
150
+
151
+ ## Deliver an event by webhook
152
+
153
+ Provide `layerWebhooks` to an object's MCP endpoint, or to its `ActionHttp.layer`, with an `HttpClient` for the deliveries, such as fetch's, and an agent that holds no request subscribes a callback with `events/subscribe`. It is the core's `Webhook.layer`, described in its `docs/Webhook.md`, kept in the object's storage and restored as the object builds after an eviction, with each wait of 10 seconds or more, a retry's backoff or a subscription's expiry, ended by the object's alarm, so the object hibernates between deliveries. The event's authentication needs `principal` and `schema`, given to `Authentication.make`: a delivery restored after an eviction runs as the subscriber its `schema` kept. What a callback's address check is in an object is on `ActionDurableObject.layerWebhooks`. A Worker serving an object's events through a placement cannot deliver them: nothing in it outlives a request. A test answers the object's deliveries with Miniflare's `outboundService`, given to `@cloudflare/vitest-plugin`'s `miniflare` options.
@@ -0,0 +1,18 @@
1
+ # ActionDurableObject failure modes
2
+
3
+ Messages the library does not own, workerd's, the compiler's and Effect's, and the fix. Search for a fragment of the message; one the library throws says its own fix. The tasks are in [ActionDurableObject.md](ActionDurableObject.md); a surface's own failures are in the core's `*-failures.md` pages.
4
+
5
+ - `is not assignable to type 'Allowed<never, never>'` at `make`'s or `handlers`' `routes`, or `Allowed<X, Y>` where the routes provide `X` and `services` provides `Y`: the routes require a service no Layer provides; those in the union other than `State`, `Alarm`, `Tasks`, `Events`, `Idempotency`, `Sockets`, `Waits`, `HttpRouter`, the platform's and `Y` are missing, so add their Layers to `services` or provide them to the routes, and a `Request<"Requires", Z>` is read per request, as the core's `ActionRpc-failures.md` says.
6
+ - `is not assignable to type 'Layer<never, never, ` at `make`'s or `handlers`' `calls`, the union `Alarm`, `Events`, `Idempotency`, `Protocol`, `State` and `Tasks`, with the services among them at `make`, sorted by name: the rpc server requires what no Layer provides, such as its group's handlers given with `RpcServer.layer(group)` alone, or a binding's authentication; provide it to the calls, or add its Layer to `services`.
7
+ - `is not assignable to type 'Layer<never, never, Alarm | Events | Idempotency | Protocol | State | Tasks>'` at `handlers`' `calls`, the source type naming a service that `services` gives with `Layer.succeed(Tag, value)`: TypeScript infers no services from the two-argument form there; write `Layer.succeed(Tag)(value)`.
8
+ - `is not assignable to type 'Layer<never, never, never>'` at `handlers`' `services`, beside an error at its `calls`: TypeScript infers no services once the calls fail; fix the calls' error, as the entries above say.
9
+ - `is not assignable to type 'Alarm | Events | Idempotency | State | Tasks'` at `make`'s or `handlers`' `services`, after `is not assignable to type 'Layer<` naming the services: they require a service other than `State`, `Alarm`, `Tasks`, `Event.Events` and `Action.Idempotency`, which nothing before them builds; provide its Layer to them, `Layer.provide`, or merge it in with `Layer.provideMerge`.
10
+ - `provides no match for the signature '(env: Env): Layer<` at `make`'s `services`: `make` takes its services as a function of `env`, never a Layer; write `services: () => layer`.
11
+ - `Property 'effectActionsRpc' is missing in type 'DurableObjectStub<...>' but required in type 'Stub'` at `layerStubClient` or `client`'s factory: the class serves no calls over its stub; give `make` `calls`, or define the method in a class of your own, delegating to `handlers`.
12
+ - `Effect<Context<Protocol>, never, Sockets>` is not assignable, or `Sockets` missing among a Layer's requirements: `layerWebSocket` registers with the dispatcher of the instance's sockets, which `make` and `handlers` provide; serve the routes through one of them.
13
+ - `The RPC receiver does not implement the method "effectActionsRpc"`, workerd's, as an `RpcClientError`'s cause: the class defines no such method; define it, delegating to `handlers` ([ActionDurableObject-own-class.md](ActionDurableObject-own-class.md)).
14
+ - `Serialized RPC arguments or return values are limited to 32MiB`, workerd's, as an `RpcClientError`'s cause: the call's request or answer passed Workers RPC's limit; page large answers, or keep large values in storage, R2 or a socket's stream.
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
+ - `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
+ - `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.
@@ -0,0 +1,9 @@
1
+ # ActionDurableObject in a class of your own
2
+
3
+ A class with methods, sockets or handlers of its own extends the one `make` returns and overrides them, its `services` a function of its `env` ([ActionDurableObject.md](ActionDurableObject.md)). This page is for a class that cannot, such as one extending another base class: it delegates to the handlers `make` is built from. A message the library throws says its fix; for workerd's, a compiler diagnostic or Effect's own error, search [ActionDurableObject-failures.md](ActionDurableObject-failures.md).
4
+
5
+ ## Delegate to handlers
6
+
7
+ `handlers(this.ctx, options)` takes `make`'s options, with `services` a Layer, built from `this.env` when they read it, and returns the object's handlers, each taking the runtime's own arguments. Keep it in a private field, delegate the handlers the class serves this way, and write the others. A class serving `calls` defines the method a stub calls, delegating: `effectActionsRpc(request: ActionDurableObject.StubRequest) { return this.#object.effectActionsRpc(request); }`. Its own methods run their Effects with `run(effect)`, which gives them the services, `State`, `Alarm`, `Event.Events` and a `Scope`, and stay callable over the stub beside it. Delegate `alarm` too, given an `alarm` option or not: `Alarm`'s times and `layerWebhooks`' waits run through it.
8
+
9
+ How the object builds, when it builds again, and which handlers resolve without a build are on `ActionDurableObject.handlers`; the rules of each option on `ActionDurableObject.Options`; what `run` builds per call on `ActionDurableObject.Handlers`' `run`.
@@ -0,0 +1,41 @@
1
+ # ActionDurableObject: placement
2
+
3
+ A **placement** says which Durable Object serves each call and each watch of an HTTP binding's contracts: the one its contract's **key** names for its input, `getByName(key(input))`. The Worker serves the contracts on any surface without implementing them, sending each call on to its object, so one client, or one agent's MCP connection, reaches every object. Read [ActionDurableObject.md](ActionDurableObject.md) first; what the Worker sends on and what comes back is on the core's `ActionHttp.placed`, which this builds on.
4
+
5
+ ## Place a binding's contracts in objects
6
+
7
+ Each contract names its object in its input, chat-rooms' room in every action and in `MessagePosted`, and one HTTP binding holds them, served by the Worker and by each object alike:
8
+
9
+ ```ts example=chat-rooms/src/Api.ts
10
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
11
+
12
+ import { MessagePosted, Messages } from "./chat/Chat.js";
13
+ import { Login } from "./identity/Identity.js";
14
+ import { Previews } from "./lobby/Lobby.js";
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
+ export const RoomHttp = ActionHttp.make([...Messages, MessagePosted], {
19
+ authentication: Login,
20
+ });
21
+
22
+ // The Worker's own JSON routes.
23
+ export const LobbyHttp = ActionHttp.make([Previews]);
24
+ ```
25
+
26
+ Then, as `routes.ts` on [ActionDurableObject.md](ActionDurableObject.md) does:
27
+
28
+ - `ActionDurableObject.placed(RoomHttp, { readMessages: byRoom, … })` is the placement, shared by both sides: a key for each contract of the binding, by its name, each reading its own contract's input, so a contract added to the binding without one is a type error.
29
+ - Each object's routes serve the binding in `ActionHttp.layer`, where each call arrives, beside whatever else they serve, a page's socket among them.
30
+ - 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
+ - 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
+
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.
34
+
35
+ ## Serve them over MCP from the Worker
36
+
37
+ Nothing in a Worker outlives a request, so its MCP endpoint is `requestScoped`, as `routes.ts`'s is: MCP 2026-07-28 alone, and an event by `events/poll` alone, since a request-scoped server pushes nothing. An event that agents follow by stream or by webhook is served from its object's own endpoint instead, as [ActionDurableObject-events.md](ActionDurableObject-events.md) shows, one connection per object. Where every event lives in one object, such as a single lobby, that object serves the one MCP endpoint, its own implementations beside `reaching` the other objects' actions, and the Worker forwards `/mcp` to it.
38
+
39
+ ## What a watch through the Worker costs
40
+
41
+ Cloudflare bills a Worker for its requests and CPU time, not for how long a request stays open, and an object for the wall-clock time it is active or cannot hibernate, as its pricing page says. A watch held through the Worker, an HTTP client's stream, holds a request to the object open for as long as it lasts, so the object is billed for all of it; a poll lets it hibernate between requests. An event an object's socket serves through a placement of another object's holds both awake: serve an event in the object whose socket follows it, as `ActionDurableObject.reaching` says.