@gjermundgaraba/effect-actions-cloudflare 0.0.0-stage → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/dist/ActionDurableObject.d.mts +240 -0
- package/dist/ActionDurableObject.mjs +190 -0
- package/dist/alarm.d.mts +42 -0
- package/dist/alarm.mjs +155 -0
- package/dist/batches.mjs +10 -0
- package/dist/calls.d.mts +9 -0
- package/dist/calls.mjs +92 -0
- package/dist/events.mjs +131 -0
- package/dist/fetcher.d.mts +17 -0
- package/dist/fetcher.mjs +19 -0
- package/dist/frames.mjs +33 -0
- package/dist/idempotency.mjs +40 -0
- package/dist/placed.d.mts +47 -0
- package/dist/placed.mjs +53 -0
- package/dist/sockets.d.mts +115 -0
- package/dist/sockets.mjs +243 -0
- package/dist/state.d.mts +18 -0
- package/dist/state.mjs +18 -0
- package/dist/stub.d.mts +59 -0
- package/dist/stub.mjs +75 -0
- package/dist/subscriptions.mjs +104 -0
- package/dist/tasks.d.mts +24 -0
- package/dist/tasks.mjs +31 -0
- package/dist/webhooks.d.mts +36 -0
- package/dist/webhooks.mjs +108 -0
- package/docs/ActionDurableObject-events.md +153 -0
- package/docs/ActionDurableObject-failures.md +18 -0
- package/docs/ActionDurableObject-own-class.md +9 -0
- package/docs/ActionDurableObject-placement.md +41 -0
- package/docs/ActionDurableObject-stub.md +135 -0
- package/docs/ActionDurableObject-testing.md +35 -0
- package/docs/ActionDurableObject.md +235 -0
- package/docs/README.md +28 -0
- package/examples/chat-rooms/README.md +97 -0
- package/examples/chat-rooms/src/Api.ts +14 -0
- package/examples/chat-rooms/src/Rpc.ts +22 -0
- package/examples/chat-rooms/src/browser.ts +107 -0
- package/examples/chat-rooms/src/chat/Chat.ts +95 -0
- package/examples/chat-rooms/src/chat/activity.ts +16 -0
- package/examples/chat-rooms/src/chat/authorize.ts +22 -0
- package/examples/chat-rooms/src/chat/events.ts +64 -0
- package/examples/chat-rooms/src/chat/handlers.ts +38 -0
- package/examples/chat-rooms/src/chat/room.ts +12 -0
- package/examples/chat-rooms/src/chat/store.ts +25 -0
- package/examples/chat-rooms/src/env.d.ts +12 -0
- package/examples/chat-rooms/src/identity/Identity.ts +21 -0
- package/examples/chat-rooms/src/identity/members.ts +8 -0
- package/examples/chat-rooms/src/identity/verifier.ts +24 -0
- package/examples/chat-rooms/src/lobby/Calls.ts +14 -0
- package/examples/chat-rooms/src/lobby/Lobby.ts +32 -0
- package/examples/chat-rooms/src/lobby/events.ts +17 -0
- package/examples/chat-rooms/src/lobby/handlers.ts +34 -0
- package/examples/chat-rooms/src/lobby/host.ts +10 -0
- package/examples/chat-rooms/src/lobby/store.ts +18 -0
- package/examples/chat-rooms/src/platform/kv-previews.ts +35 -0
- package/examples/chat-rooms/src/platform/lobby-stubs.ts +46 -0
- package/examples/chat-rooms/src/platform/sql-store.ts +110 -0
- package/examples/chat-rooms/src/rooms-entrypoint.ts +33 -0
- package/examples/chat-rooms/src/routes.ts +89 -0
- package/examples/chat-rooms/src/worker.ts +99 -0
- package/examples/chat-rooms/test/platform/sql-store.test.ts +132 -0
- package/examples/chat-rooms/test/support/socket.ts +70 -0
- package/examples/chat-rooms/test/worker.test.ts +579 -0
- package/examples/chat-rooms/tsconfig.json +22 -0
- package/examples/chat-rooms/vite.config.ts +19 -0
- package/examples/chat-rooms/wrangler.jsonc +15 -0
- package/package.json +49 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# ActionDurableObject over the stub
|
|
2
|
+
|
|
3
|
+
A **stub** is what a Worker, or another object, reaches an object through, `env.<BINDING>.getByName(name)`, and a **stub call** a call of one of the object's methods over it, with Cloudflare's own RPC, Workers RPC. An object serves rpcs on one such method, `effectActionsRpc`, named for the package so it does not collide with the class's own, and a caller calls them with a typed client whose errors are the declared ones. Read [ActionDurableObject.md](ActionDurableObject.md) first. 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
|
+
## Serve an object's own API over its stub
|
|
6
|
+
|
|
7
|
+
Declare what only the Worker and other objects call as a plain Effect `RpcGroup`:
|
|
8
|
+
|
|
9
|
+
```ts example=chat-rooms/src/lobby/Calls.ts
|
|
10
|
+
import { Schema } from "effect";
|
|
11
|
+
import { Rpc, RpcGroup } from "effect/rpc";
|
|
12
|
+
|
|
13
|
+
import { RoomPreview } from "./Lobby.js";
|
|
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
|
+
export const LobbyCalls = RpcGroup.make(
|
|
21
|
+
Rpc.make("roomChanged", { payload: RoomPreview }),
|
|
22
|
+
Rpc.make("previews", { success: Schema.Array(RoomPreview) })
|
|
23
|
+
);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
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
|
+
|
|
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`.
|
|
29
|
+
|
|
30
|
+
## Call it from a Worker or another object
|
|
31
|
+
|
|
32
|
+
`client(group)` makes one client, in the caller's `Scope`, for every object of a class, and returns `(stubOf) => client`: each call gets a new stub from `stubOf`, as it is sent, and fails with the rpc's declared errors or the stub's own, an `RpcClientError`, which the caller retries or makes a defect. The Worker reads the previews through `layerLobbyHost`, and a room tells the lobby through `tellLobby`, a task, below:
|
|
33
|
+
|
|
34
|
+
```ts example=chat-rooms/src/platform/lobby-stubs.ts
|
|
35
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
36
|
+
import { env } from "cloudflare:workers";
|
|
37
|
+
import { Effect, Layer } from "effect";
|
|
38
|
+
|
|
39
|
+
import { Activity } from "../chat/activity.js";
|
|
40
|
+
import { Room } from "../chat/room.js";
|
|
41
|
+
import { MessageStore } from "../chat/store.js";
|
|
42
|
+
import { LobbyCalls } from "../lobby/Calls.js";
|
|
43
|
+
import { LobbyHost } from "../lobby/host.js";
|
|
44
|
+
|
|
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
|
+
const lobbyStub = () => env.CHAT_LOBBY.getByName("lobby");
|
|
48
|
+
|
|
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
|
+
export const layerLobbyHost = Layer.effect(LobbyHost)(
|
|
53
|
+
Effect.map(ActionDurableObject.client(LobbyCalls), (lobby) =>
|
|
54
|
+
LobbyHost.of({ previews: Effect.orDie(lobby(lobbyStub).previews()) })
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
|
|
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
|
+
export const tellLobby = Effect.gen(function* () {
|
|
65
|
+
const room = yield* Room;
|
|
66
|
+
const latest = yield* MessageStore.use((store) => store.latest);
|
|
67
|
+
const lobby = yield* ActionDurableObject.client(LobbyCalls);
|
|
68
|
+
|
|
69
|
+
yield* lobby(lobbyStub).roomChanged({ room, latestAt: latest?.at ?? null });
|
|
70
|
+
});
|
|
71
|
+
|
|
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
|
+
export const layerActivity = Layer.effect(Activity)(
|
|
77
|
+
Effect.map(ActionDurableObject.Tasks, (tasks) =>
|
|
78
|
+
Activity.of({ changing: (change) => tasks.runAfter("lobby", change) })
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Why no stub is held, when one breaks and when a call may be repeated are on `ActionDurableObject.client`. `RpcClient.make` requires a `Scope` natively, so no client without one is offered. Where a Worker builds a client, as each request or once as the module loads, and why one built lazily inside a request strands, are on `ActionDurableObject.layerStubClient`'s gotchas: chat-rooms' `worker.ts` builds its handler as the module loads. In a Durable Object, `make` and `handlers` run every Effect under the scheduler that does not strand it, as `handlers` says.
|
|
84
|
+
|
|
85
|
+
A Worker's entrypoint that is no Effect program, such as a `WorkerEntrypoint` method or a queue consumer, runs each call as its own program. chat-rooms' `RoomsEntrypoint`, which `worker.ts` exports, serves the rooms' activity to another Worker:
|
|
86
|
+
|
|
87
|
+
```ts example=chat-rooms/src/rooms-entrypoint.ts
|
|
88
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
89
|
+
import { Effect, Schema } from "effect";
|
|
90
|
+
|
|
91
|
+
import { RoomName } from "./chat/Chat.js";
|
|
92
|
+
import { LobbyHost } from "./lobby/host.js";
|
|
93
|
+
import { layerLobbyHost } from "./platform/lobby-stubs.js";
|
|
94
|
+
|
|
95
|
+
const decodeRoom = Schema.decodeUnknownEffect(RoomName);
|
|
96
|
+
|
|
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
|
+
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
|
+
async latestAt(room: string) {
|
|
107
|
+
return await Effect.runPromise(
|
|
108
|
+
Effect.flatMap(decodeRoom(room), (name) =>
|
|
109
|
+
LobbyHost.use((lobby) =>
|
|
110
|
+
Effect.map(
|
|
111
|
+
lobby.previews,
|
|
112
|
+
(previews) =>
|
|
113
|
+
previews.find((preview) => preview.room === name)?.latestAt ??
|
|
114
|
+
null
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
).pipe(Effect.provide(layerLobbyHost))
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Tell another object of a change
|
|
124
|
+
|
|
125
|
+
An object telling another of its changes, as a room tells the lobby, does it as a task: give `make` or `handlers` `tasks`, a name for each Effect, and wrap each change in `ActionDurableObject.Tasks`' `runAfter`, as `layerActivity` above does. The task is marked in the object's storage before the change and again after it, and the object's alarm runs it once the call has answered, so one that fails, or that an eviction or a crash cuts off, the change's own included, runs again until it succeeds; the caller never fails with it, and a caller reading the object told at once may find it not yet told. A task reads what it sends as it runs, so one run covers every change before it, and the object told ignores what it already holds, as the lobby's `record` does. `worker.ts` gives the room's class `tasks: { lobby: tellLobby }`. What a run marks, and when it runs again, are on `ActionDurableObject.Tasks`.
|
|
126
|
+
|
|
127
|
+
## Name the object in its errors
|
|
128
|
+
|
|
129
|
+
An object reached by `getByName` knows its name, `ActionDurableObject.name`. Make it a startup service, decoded once as the instance builds, as chat-rooms' [worker.ts](../examples/chat-rooms/src/worker.ts) makes `Room`, and build each error from it in the handler, with nothing more from the caller.
|
|
130
|
+
|
|
131
|
+
An object reached by `get(idFromString(id))` has no name: its caller sends the key in the payload, for the error to name.
|
|
132
|
+
|
|
133
|
+
## Call an object's routes
|
|
134
|
+
|
|
135
|
+
For the object's routes, `ActionHttp.client`, `ActionMcp.client`, or `ActionRpc.client` over `RpcClient.layerProtocolHttp`, give the client `layerHttpClient(stub)`: every request goes to the object's `fetch` in place of the network, as the example's tests call the Worker ([chat-rooms/test/worker.test.ts](../examples/chat-rooms/test/worker.test.ts)). Its URL and credentials are on `ActionDurableObject.layerHttpClient`. To pass a page's request on, forward the `Request` itself, as `worker.ts` does.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ActionDurableObject: testing and deploying
|
|
2
|
+
|
|
3
|
+
An object's tests run inside the Workers runtime, on the Worker its Wrangler configuration describes. Read [ActionDurableObject.md](ActionDurableObject.md) first. 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
|
+
## Test on the Workers runtime
|
|
6
|
+
|
|
7
|
+
Run the tests inside the runtime with `@cloudflare/vitest-plugin`, on the Worker the Wrangler configuration describes:
|
|
8
|
+
|
|
9
|
+
```ts example=chat-rooms/vite.config.ts
|
|
10
|
+
import { cloudflareTest } from "@cloudflare/vitest-plugin";
|
|
11
|
+
import { defineConfig } from "vite-plus";
|
|
12
|
+
|
|
13
|
+
// Its tests run inside the Workers runtime, on the Worker wrangler.jsonc describes.
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
plugins: [
|
|
16
|
+
cloudflareTest({
|
|
17
|
+
wrangler: { configPath: "./wrangler.jsonc" },
|
|
18
|
+
}),
|
|
19
|
+
],
|
|
20
|
+
test: {
|
|
21
|
+
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
|
+
fsModuleCache: false,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From `cloudflare:test`, `evictDurableObject(stub)` evicts an instance as hibernation does, its sockets staying open, the runtime runs an alarm set in the past at once, which chat-rooms' `test/worker.test.ts` waits for, and `runInDurableObject(stub, fn)` reads or changes the object's storage directly. Call the Worker as a deployment would with `layerHttpClient(exports.default)`, and open a socket by upgrading through its `fetch`, handing `response.webSocket` to `Socket.fromWebSocket` under `RpcClient.layerProtocolSocket` ([chat-rooms/test/support/socket.ts](../examples/chat-rooms/test/support/socket.ts), [chat-rooms/test/worker.test.ts](../examples/chat-rooms/test/worker.test.ts)). The core's `Testing.layer` serves the same routes in memory, but cannot upgrade a socket. Under **celld**, Deno's self-hosted Durable Object runtime, `CELLD_IDLE_EVICT_S` is the equivalent of `evictDurableObject`: the idle seconds after which it evicts an object.
|
|
32
|
+
|
|
33
|
+
## Deploy
|
|
34
|
+
|
|
35
|
+
workerd, Cloudflare's runtime, serves an object's sockets under any `compatibility_date`. celld 0.6.2 differs from it twice, each difference a gotcha of the declaration it touches: a socket's upgrade headers after an eviction, on `ActionDurableObject.layerWebSocket`, and an alarm set on a class without an `alarm` method, on `ActionDurableObject.handlers`. What an eviction loses is on `ActionDurableObject.handlers`.
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# ActionDurableObject
|
|
2
|
+
|
|
3
|
+
Actions served from a Cloudflare Durable Object. A **Durable Object** is a class a Worker's Wrangler configuration binds, with one **object** per id or name, running in one place at a time with its own storage. The runtime constructs an **instance** of the class for an object's first event, **evicts** it when idle, and constructs another for the next. An object serves the routes the core serves elsewhere, Effect RPC on WebSockets the runtime holds open while the object **hibernates**, evicted with its sockets kept, rpcs over its stub, and an alarm. Reach for it when state belongs to one thing, such as a room, a document or a tenant, and its callers should meet in one place.
|
|
4
|
+
|
|
5
|
+
Import `@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject`. 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). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions-cloudflare/dist/`: each declaration's JSDoc carries its rules and gotchas, and this page only what an example cannot show. Events are on [ActionDurableObject-events.md](ActionDurableObject-events.md), calls over the stub on [ActionDurableObject-stub.md](ActionDurableObject-stub.md), tests and runtimes on [ActionDurableObject-testing.md](ActionDurableObject-testing.md), and a class that cannot extend `make`'s on [ActionDurableObject-own-class.md](ActionDurableObject-own-class.md).
|
|
6
|
+
|
|
7
|
+
## Serve actions from an object
|
|
8
|
+
|
|
9
|
+
Give `make` the routes, as the core's surfaces build them, and their startup services, and export the class it returns, or one extending it, under the name the Wrangler configuration binds. The entrypoint holds the room's class beside the Worker's own `fetch`:
|
|
10
|
+
|
|
11
|
+
```ts example=chat-rooms/src/worker.ts
|
|
12
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
13
|
+
import { Effect, Layer, Schema } from "effect";
|
|
14
|
+
import { HttpRouter, HttpServer } from "effect/http";
|
|
15
|
+
|
|
16
|
+
import { Activity } from "./chat/activity.js";
|
|
17
|
+
import { RoomName } from "./chat/Chat.js";
|
|
18
|
+
import { Room } from "./chat/room.js";
|
|
19
|
+
import { MessageStore } from "./chat/store.js";
|
|
20
|
+
import { layerKvPreviews } from "./platform/kv-previews.js";
|
|
21
|
+
import {
|
|
22
|
+
layerActivity,
|
|
23
|
+
layerLobbyHost,
|
|
24
|
+
tellLobby,
|
|
25
|
+
} from "./platform/lobby-stubs.js";
|
|
26
|
+
import { layerSql } from "./platform/sql-store.js";
|
|
27
|
+
import {
|
|
28
|
+
lobbyCalls,
|
|
29
|
+
lobbyRoutes,
|
|
30
|
+
roomRoutes,
|
|
31
|
+
rooms,
|
|
32
|
+
workerRoutes,
|
|
33
|
+
} from "./routes.js";
|
|
34
|
+
|
|
35
|
+
// The rooms for another Worker, over a service binding: a named entrypoint of this one.
|
|
36
|
+
export { RoomsEntrypoint } from "./rooms-entrypoint.js";
|
|
37
|
+
|
|
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.
|
|
40
|
+
|
|
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
|
+
export class ChatRoom extends ActionDurableObject.make({
|
|
52
|
+
placed: rooms,
|
|
53
|
+
services: () =>
|
|
54
|
+
Layer.mergeAll(layerSql, layerActivity).pipe(
|
|
55
|
+
Layer.provideMerge(
|
|
56
|
+
Layer.effect(Room)(Effect.flatMap(ActionDurableObject.name, decodeRoom))
|
|
57
|
+
)
|
|
58
|
+
),
|
|
59
|
+
routes: roomRoutes,
|
|
60
|
+
tasks: { lobby: tellLobby },
|
|
61
|
+
alarm: () =>
|
|
62
|
+
Activity.use((activity) =>
|
|
63
|
+
activity.changing(MessageStore.use((store) => store.expire))
|
|
64
|
+
),
|
|
65
|
+
}) {}
|
|
66
|
+
|
|
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
|
+
export class ChatLobby extends ActionDurableObject.make({
|
|
70
|
+
services: () => layerKvPreviews,
|
|
71
|
+
routes: lobbyRoutes,
|
|
72
|
+
calls: lobbyCalls,
|
|
73
|
+
}) {}
|
|
74
|
+
|
|
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
|
+
const served = HttpRouter.toWebHandler(
|
|
80
|
+
workerRoutes.pipe(
|
|
81
|
+
Layer.provide(layerLobbyHost),
|
|
82
|
+
Layer.provide(HttpServer.layerServices)
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const isRoomName = Schema.is(RoomName);
|
|
87
|
+
|
|
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
|
+
export default {
|
|
92
|
+
async fetch(request: Request, env: Cloudflare.Env) {
|
|
93
|
+
const url = new URL(request.url);
|
|
94
|
+
|
|
95
|
+
const room =
|
|
96
|
+
/^\/rooms\/(?<room>[^/]+)\/rpc$/u.exec(url.pathname)?.groups?.room ?? "";
|
|
97
|
+
|
|
98
|
+
if (url.pathname === "/lobby/rpc" || isRoomName(room)) {
|
|
99
|
+
url.pathname = "/rpc";
|
|
100
|
+
|
|
101
|
+
const object = isRoomName(room)
|
|
102
|
+
? env.CHAT_ROOM.getByName(room)
|
|
103
|
+
: env.CHAT_LOBBY.getByName("lobby");
|
|
104
|
+
|
|
105
|
+
return await object.fetch(new Request(url, request));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return await served.handler(request);
|
|
109
|
+
},
|
|
110
|
+
} satisfies ExportedHandler<Cloudflare.Env>;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
[chat-rooms/wrangler.jsonc](../examples/chat-rooms/wrangler.jsonc) binds each class with a SQLite migration. The rules of each option, when it builds and what it may require, are on `ActionDurableObject.Options`; what an instance builds, and when again, on `ActionDurableObject.handlers`; the class itself on `ActionDurableObject.make`, whose `services` is a function of the instance's `env`, `() => layer` for services reading none.
|
|
114
|
+
|
|
115
|
+
- A service read from the object is a Layer requiring `State`: a store over its SQLite database ([chat-rooms/src/platform/sql-store.ts](../examples/chat-rooms/src/platform/sql-store.ts)), or what the object is, such as its name, `ActionDurableObject.name`, the room in `worker.ts` above.
|
|
116
|
+
- An authorizer that depends on the object, such as on its room, yields that startup service once, when it is built ([chat-rooms/src/chat/authorize.ts](../examples/chat-rooms/src/chat/authorize.ts)).
|
|
117
|
+
- A class extending the one `make` returns overrides only the handlers it has reason to write, and reaches `make`'s with `super`; a socket it accepts itself is its own to answer.
|
|
118
|
+
|
|
119
|
+
## Serve rpcs over hibernating WebSockets
|
|
120
|
+
|
|
121
|
+
Give `ActionRpc.layer` the protocol `layerWebSocket` makes, at a path of the object's, beside the JSON routes in one routes Layer:
|
|
122
|
+
|
|
123
|
+
```ts example=chat-rooms/src/routes.ts
|
|
124
|
+
import * as ActionDurableObject from "@gjermundgaraba/effect-actions-cloudflare/ActionDurableObject";
|
|
125
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
126
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
127
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
128
|
+
import { env } from "cloudflare:workers";
|
|
129
|
+
import { Layer } from "effect";
|
|
130
|
+
import { RpcServer } from "effect/rpc";
|
|
131
|
+
|
|
132
|
+
import { LobbyHttp, RoomHttp } from "./Api.js";
|
|
133
|
+
import { messageEvents } from "./chat/events.js";
|
|
134
|
+
import { messages } from "./chat/handlers.js";
|
|
135
|
+
import { Login } from "./identity/Identity.js";
|
|
136
|
+
import { authenticate } from "./identity/verifier.js";
|
|
137
|
+
import { LobbyCalls } from "./lobby/Calls.js";
|
|
138
|
+
import { previewEvents } from "./lobby/events.js";
|
|
139
|
+
import { calls as lobbyHandlers, lobby } from "./lobby/handlers.js";
|
|
140
|
+
import {
|
|
141
|
+
LobbyRpc,
|
|
142
|
+
protocolVersion,
|
|
143
|
+
RoomRpc,
|
|
144
|
+
unsupportedProtocol,
|
|
145
|
+
} from "./Rpc.js";
|
|
146
|
+
|
|
147
|
+
const room = [messages, messageEvents];
|
|
148
|
+
|
|
149
|
+
const byRoom = (input: { readonly room: string }) => input.room;
|
|
150
|
+
|
|
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
|
+
export const rooms = ActionDurableObject.placed(RoomHttp, {
|
|
155
|
+
readMessages: byRoom,
|
|
156
|
+
postMessage: byRoom,
|
|
157
|
+
deleteMessage: byRoom,
|
|
158
|
+
"message.posted": byRoom,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// One socket route for each object's pages, refusing a page of another version.
|
|
162
|
+
const pageSocket = ActionDurableObject.layerWebSocket({
|
|
163
|
+
path: "/rpc",
|
|
164
|
+
protocol: { version: protocolVersion, code: unsupportedProtocol },
|
|
165
|
+
});
|
|
166
|
+
|
|
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
|
+
export const roomRoutes = Layer.mergeAll(
|
|
176
|
+
ActionRpc.layer(RoomRpc, room).pipe(Layer.provide(pageSocket)),
|
|
177
|
+
ActionHttp.layer(RoomHttp, room)
|
|
178
|
+
).pipe(Layer.provide(authenticate));
|
|
179
|
+
|
|
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
|
+
export const lobbyRoutes = ActionRpc.layer(LobbyRpc, [previewEvents]).pipe(
|
|
183
|
+
Layer.provide(pageSocket)
|
|
184
|
+
);
|
|
185
|
+
|
|
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
|
+
export const lobbyCalls = RpcServer.layer(LobbyCalls).pipe(
|
|
190
|
+
Layer.provide(lobbyHandlers)
|
|
191
|
+
);
|
|
192
|
+
|
|
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
|
+
const inRooms = ActionDurableObject.reaching(rooms, () => env.CHAT_ROOM);
|
|
196
|
+
|
|
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
|
+
export const workerRoutes = Layer.mergeAll(
|
|
204
|
+
ActionHttp.layer(LobbyHttp, lobby),
|
|
205
|
+
ActionHttp.layer(RoomHttp, inRooms),
|
|
206
|
+
ActionMcp.layerHttp([lobby, ...inRooms], {
|
|
207
|
+
name: "chat-rooms",
|
|
208
|
+
version: "1.0.0",
|
|
209
|
+
authentication: Login,
|
|
210
|
+
requestScoped: true,
|
|
211
|
+
})
|
|
212
|
+
).pipe(Layer.provide(authenticate));
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
What a socket keeps across hibernation, how each message authenticates, and what the runtime answers itself are on `ActionDurableObject.layerWebSocket`. Its `protocol` option refuses a page naming another version of the rpcs in its URL with a close code the page's client reads, as a browser cannot read a refused upgrade's status; the page's client stops connecting at that code given the core's `ActionRpc.reconnectUntil` as its `retryPolicy`. Router middleware provided to it runs for the upgrade alone, and refuses one by any other judge, such as a foreign `Origin`, with `ActionDurableObject.refuseUpgrade`. A page's client speaks JSON over `RpcClient.layerProtocolSocket` and `Socket.layerWebSocket(url)`, its token on each message ([chat-rooms/src/browser.ts](../examples/chat-rooms/src/browser.ts)), and the Worker forwards a page's upgrade to the room's object by forwarding its `Request`, as `worker.ts` does above.
|
|
216
|
+
|
|
217
|
+
## Follow an event
|
|
218
|
+
|
|
219
|
+
List an event's implementation beside the actions in `ActionRpc.layer`, as `routes.ts` above does, and a page watches it on its socket, an agent over MCP or by webhook: [ActionDurableObject-events.md](ActionDurableObject-events.md).
|
|
220
|
+
|
|
221
|
+
## Serve the objects' actions from the Worker
|
|
222
|
+
|
|
223
|
+
Give each of a binding's contracts a key of its input, `rooms` in `routes.ts` above, and the Worker serves them on any surface, each call sent on to the object the key names, which refuses one naming another: [ActionDurableObject-placement.md](ActionDurableObject-placement.md).
|
|
224
|
+
|
|
225
|
+
## Run an alarm
|
|
226
|
+
|
|
227
|
+
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
|
+
|
|
229
|
+
## Call an object from a Worker
|
|
230
|
+
|
|
231
|
+
Call its `calls` with `client`, the declared errors typed, and its routes with the core's clients over `layerHttpClient(stub)`: [ActionDurableObject-stub.md](ActionDurableObject-stub.md).
|
|
232
|
+
|
|
233
|
+
## Test and deploy
|
|
234
|
+
|
|
235
|
+
Test an object on the Workers runtime, and what differs between runtimes: [ActionDurableObject-testing.md](ActionDurableObject-testing.md).
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# effect-actions-cloudflare
|
|
2
|
+
|
|
3
|
+
`@gjermundgaraba/effect-actions-cloudflare` serves Effect RPC from Cloudflare Durable Objects, over WebSockets the runtime holds while the object hibernates: any plain `RpcGroup`, on Effect's own `RpcServer`, with no action at all. Actions are the option: the routes `ActionHttp.layer`, `ActionRpc.layer` and `ActionMcp.layerHttp` serve elsewhere, in an object, its events kept in its storage and followed over its sockets, over MCP or by webhook. A Worker, or another object, calls an object's rpcs over its **stub**, the handle `getByName(name)` gives, with Cloudflare's own RPC, or its routes with the core's own clients.
|
|
4
|
+
|
|
5
|
+
These pages cover what this package adds. Everything else, contracts, implementations, authentication and each surface, is in the core package's docs, at `node_modules/@gjermundgaraba/effect-actions/docs/`: read its `docs/README.md` first, then its `docs/Action.md`, and the page of each surface you serve.
|
|
6
|
+
|
|
7
|
+
## How to read these pages
|
|
8
|
+
|
|
9
|
+
**Arriving with an error?** A message the library throws says its fix; for any other, step 4.
|
|
10
|
+
|
|
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.
|
|
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
|
+
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
|
+
|
|
16
|
+
Where things are, in a project that installs the package: these pages in `node_modules/@gjermundgaraba/effect-actions-cloudflare/docs/`, the example in `examples/` beside them, and the declarations with their JSDoc, the reference for rules and gotchas, under `dist/`. Install it beside the core, at the same version: the two are released together. Its declarations name the Workers runtime's types, from `wrangler types` or `@cloudflare/workers-types`.
|
|
17
|
+
|
|
18
|
+
## Pages
|
|
19
|
+
|
|
20
|
+
| Read | When you need to |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| [ActionDurableObject.md](ActionDurableObject.md) | serve an `RpcGroup`, or actions, from an object, over hibernating WebSockets and JSON; run its alarm |
|
|
23
|
+
| [ActionDurableObject-events.md](ActionDurableObject-events.md) | follow an event over an object's socket, serve actions and events over MCP, deliver events by webhook |
|
|
24
|
+
| [ActionDurableObject-stub.md](ActionDurableObject-stub.md) | serve an object's own API over its stub, call it from a Worker, tell another object of a change until it hears, name the object in errors, call its routes |
|
|
25
|
+
| [ActionDurableObject-placement.md](ActionDurableObject-placement.md) | serve objects' actions and events from the Worker, each call reaching the object its input names |
|
|
26
|
+
| [ActionDurableObject-own-class.md](ActionDurableObject-own-class.md) | serve them from a class that cannot extend `make`'s, delegating to `handlers` |
|
|
27
|
+
| [ActionDurableObject-testing.md](ActionDurableObject-testing.md) | test an object on the Workers runtime, evict it, run its alarm in a test; what differs between workerd and celld |
|
|
28
|
+
| [ActionDurableObject-failures.md](ActionDurableObject-failures.md) | find what workerd's, the compiler's or Effect's message means, and the fix |
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# chat-rooms
|
|
2
|
+
|
|
3
|
+
Chat rooms on Cloudflare: each room is one Durable Object, holding its messages in its own SQLite database and its pages' WebSockets while it hibernates. Members read, post and delete messages in the rooms they belong to over the room's socket, or the Worker's JSON routes and MCP endpoint, which reach every room. A page follows its room through an event, `MessagePosted`, its subscription kept while the object hibernates; an agent polls it over MCP. A message expires a day after it was posted, when the room's alarm runs. A lobby, one more object, keeps when each room was last active: each room tells it its latest activity over its stub, with Cloudflare's own RPC, and a page follows it, every preview whole at each change. The Worker in front serves the rooms' JSON routes and MCP endpoint itself, sending each call on to the room its input names, serves the previews, asking the lobby, and forwards a page's socket, `/rooms/<room>/rpc`, to the room's object and `/lobby/rpc` to the lobby's.
|
|
4
|
+
|
|
5
|
+
Start here to serve actions from Durable Objects. The same chat on a long-lived server is live-rpc, in the core package's `examples/`.
|
|
6
|
+
|
|
7
|
+
## Run it
|
|
8
|
+
|
|
9
|
+
From the repository root, after `vp install`:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
vp test packages/cloudflare/examples/chat-rooms
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The tests run inside the Workers runtime, on the Worker `wrangler.jsonc` describes, with `@cloudflare/vitest-plugin`. `browser.ts` is imported by a page, not run. The tokens are fixed names (`ada`, `grace`): replace them before deploying anything.
|
|
16
|
+
|
|
17
|
+
In a project that installs the package, copy this directory out of `node_modules/@gjermundgaraba/effect-actions-cloudflare/examples/`. It imports both packages by name and needs `effect` beside them, `@cloudflare/workers-types` for its `tsconfig.json`, and `wrangler` to serve it (`wrangler dev`, `wrangler deploy` in its directory). Its tests need `vitest`, `@effect/vitest` and `@cloudflare/vitest-plugin`; `vite.config.ts` imports `defineConfig` from the repository's toolchain, `vite-plus`, which is Vitest's `vitest/config` elsewhere.
|
|
18
|
+
|
|
19
|
+
## When you copy this
|
|
20
|
+
|
|
21
|
+
Each trap is a comment in the file named.
|
|
22
|
+
|
|
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`.
|
|
25
|
+
- **The Worker serves every room, each room answers**, through the placement `rooms`: `src/routes.ts`.
|
|
26
|
+
- **A page reads, then watches from the last message it holds**: `src/browser.ts`, `src/chat/events.ts`.
|
|
27
|
+
- **`postMessage` notifies `MessagePosted`**: `src/chat/handlers.ts`, `src/chat/events.ts`.
|
|
28
|
+
- **Objects call each other over their stubs, not their `fetch`**: `src/lobby/Calls.ts`, `src/platform/lobby-stubs.ts`.
|
|
29
|
+
- **A room tells the lobby its latest activity, and the lobby notifies**: `tellLobby` in `src/platform/lobby-stubs.ts`, `src/lobby/handlers.ts`.
|
|
30
|
+
- **The lobby's event is a whole value**, followed with no read first: `src/lobby/events.ts`, `src/browser.ts`.
|
|
31
|
+
|
|
32
|
+
## Layout
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
src/
|
|
36
|
+
├── chat/
|
|
37
|
+
│ ├── Chat.ts contract: the rooms, the message, its errors, the room's actions
|
|
38
|
+
│ ├── room.ts Room, the room an instance holds: a startup service
|
|
39
|
+
│ ├── activity.ts Activity, where a room says it changed: the lobby
|
|
40
|
+
│ ├── store.ts MessageStore, the service the handlers call
|
|
41
|
+
│ ├── events.ts MessagePosted's check source, read from the store
|
|
42
|
+
│ ├── authorize.ts the one authorization rule: a member of the instance's room
|
|
43
|
+
│ └── handlers.ts Action.implement: contract to store
|
|
44
|
+
├── identity/
|
|
45
|
+
│ ├── Identity.ts contract: CurrentMember and the Login descriptor
|
|
46
|
+
│ ├── members.ts the demo members
|
|
47
|
+
│ └── verifier.ts Authentication.layer: tokens to members, server only
|
|
48
|
+
├── lobby/
|
|
49
|
+
│ ├── Lobby.ts contract: the previews, the Worker's action and the lobby's event
|
|
50
|
+
│ ├── Calls.ts the lobby's own API over its stub, for the rooms and the Worker
|
|
51
|
+
│ ├── store.ts PreviewStore, the previews the lobby keeps
|
|
52
|
+
│ ├── events.ts PreviewsChanged's current source, the whole value
|
|
53
|
+
│ ├── host.ts LobbyHost, the lobby's object as the Worker reaches it
|
|
54
|
+
│ └── handlers.ts the Worker's action, and the lobby's calls recording a room's activity
|
|
55
|
+
├── platform/
|
|
56
|
+
│ ├── sql-store.ts MessageStore over the object's SQLite database, and its alarm
|
|
57
|
+
│ ├── kv-previews.ts PreviewStore over the lobby's synchronous key-value storage
|
|
58
|
+
│ └── lobby-stubs.ts LobbyHost, Activity and the room's task over the lobby's stub
|
|
59
|
+
├── Api.ts the JSON bindings: a room's and the lobby's
|
|
60
|
+
├── Rpc.ts the RPC bindings: a room's actions and event, and the lobby's event
|
|
61
|
+
├── routes.ts each object's routes, the lobby's calls over its stub, the Worker's routes
|
|
62
|
+
├── worker.ts entrypoint: the room's and the lobby's classes and the Worker's fetch
|
|
63
|
+
├── rooms-entrypoint.ts the rooms' activity for another Worker, a program per call
|
|
64
|
+
├── browser.ts client module: a page's socket, its room and the lobby
|
|
65
|
+
└── env.d.ts the bindings' types, as `wrangler types` writes them
|
|
66
|
+
test/
|
|
67
|
+
├── platform/ the store against a real object's database
|
|
68
|
+
├── support/ a page's socket, upgraded through the Worker
|
|
69
|
+
└── worker.test.ts through the Worker's fetch: sockets, JSON routes, MCP, the lobby, the alarm
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **An instance is built per wake.** The runtime evicts an idle object and constructs a new instance for its next event, which builds its services and routes again: anything in memory is gone, and `layerSql` creates its table if it is not there. The database and the sockets stay.
|
|
73
|
+
- **The room is a startup service, the caller is not.** An instance holds one room, so the authorizer yields `Room` once, when it is built, as a builder yields a store. `CurrentMember` comes from authentication, per message.
|
|
74
|
+
- **The entrypoint chooses everything.** `worker.ts` gives a room's object the SQLite store, the room, its name, and the lobby's stub as its services, which an instance builds once for the routes, the task and the alarm; it gives the lobby's object its store, and the Worker's handler the lobby's stub.
|
|
75
|
+
- **The Worker forwards a socket, the object answers.** Forwarding a request forwards its upgrade, so the Worker passes a page's socket request to the room's object unchanged but its path; every other call it serves itself, through the placement.
|
|
76
|
+
|
|
77
|
+
## Where to look
|
|
78
|
+
|
|
79
|
+
| To see | Read |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| A Durable Object class serving actions, and its alarm | `src/worker.ts` |
|
|
82
|
+
| Rpcs on hibernating WebSockets beside JSON routes, in one object | `src/routes.ts` |
|
|
83
|
+
| An authorizer that yields the instance's room once, when it is built | `src/chat/authorize.ts`, `src/chat/handlers.ts` |
|
|
84
|
+
| A store over the object's SQLite database, setting its alarm with `Alarm` | `src/platform/sql-store.ts` |
|
|
85
|
+
| A startup service read from the object's state, its name | `src/worker.ts` |
|
|
86
|
+
| A Worker calling an object's own API over its stub, with Cloudflare's RPC | `src/lobby/Calls.ts`, `src/routes.ts`, `src/platform/lobby-stubs.ts` |
|
|
87
|
+
| An object telling another of a change, which notifies its own event | `src/platform/lobby-stubs.ts`, `src/lobby/handlers.ts` |
|
|
88
|
+
| An event sent as a whole value, followed with no read first | `src/lobby/events.ts`, `src/browser.ts`, `test/worker.test.ts` |
|
|
89
|
+
| Routing requests and upgrades from the Worker to an object | `src/worker.ts` |
|
|
90
|
+
| A page's socket client, its token on each message | `src/browser.ts` |
|
|
91
|
+
| An event a page watches on its socket, kept while the object hibernates | `src/chat/events.ts`, `src/browser.ts`, `test/worker.test.ts` |
|
|
92
|
+
| A Worker serving every room's actions and event, each in the room it names | `src/routes.ts`, `src/worker.ts`, `test/worker.test.ts` |
|
|
93
|
+
| One MCP endpoint for every room, its event polled | `src/routes.ts`, `test/worker.test.ts` |
|
|
94
|
+
| A room refusing a call that names another room | `src/chat/Chat.ts`, `src/worker.ts`, `test/worker.test.ts` |
|
|
95
|
+
| Refusing a page of another version with a close code it reads | `src/routes.ts`, `src/browser.ts` |
|
|
96
|
+
| Testing on the Workers runtime: an eviction, the alarm, the database | `test/worker.test.ts`, `test/platform/` |
|
|
97
|
+
| A socket that keeps the token of its upgrade request | `test/worker.test.ts` |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
2
|
+
|
|
3
|
+
import { MessagePosted, Messages } from "./chat/Chat.js";
|
|
4
|
+
import { Login } from "./identity/Identity.js";
|
|
5
|
+
import { Previews } from "./lobby/Lobby.js";
|
|
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
|
+
export const RoomHttp = ActionHttp.make([...Messages, MessagePosted], {
|
|
10
|
+
authentication: Login,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// The Worker's own JSON routes.
|
|
14
|
+
export const LobbyHttp = ActionHttp.make([Previews]);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
2
|
+
|
|
3
|
+
import { MessagePosted, Messages } from "./chat/Chat.js";
|
|
4
|
+
import { Login } from "./identity/Identity.js";
|
|
5
|
+
import { PreviewsChanged } from "./lobby/Lobby.js";
|
|
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
|
+
export const RoomRpc = ActionRpc.make([...Messages, MessagePosted], {
|
|
10
|
+
authentication: Login,
|
|
11
|
+
});
|
|
12
|
+
|
|
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
|
+
export const LobbyRpc = ActionRpc.make([PreviewsChanged]);
|
|
16
|
+
|
|
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
|
+
export const protocolVersion = "1";
|
|
21
|
+
|
|
22
|
+
export const unsupportedProtocol = 4000;
|