@gjermundgaraba/effect-actions 0.11.0 → 0.13.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 (215) hide show
  1. package/README.md +102 -41
  2. package/dist/Action.d.mts +31 -117
  3. package/dist/Action.mjs +14 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +23 -29
  9. package/dist/ActionMcp.mjs +56 -114
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +28 -9
  13. package/dist/ActionToolkit.mjs +31 -4
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +30 -23
  17. package/dist/Testing.mjs +27 -44
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +18 -0
  38. package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +211 -426
  46. package/docs/ActionCli-failures.md +104 -0
  47. package/docs/ActionCli.md +208 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +205 -282
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +222 -271
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +179 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +103 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +143 -392
  59. package/docs/CONTEXT.md +40 -24
  60. package/docs/README.md +75 -35
  61. package/docs/Testing-failures.md +21 -0
  62. package/docs/Testing.md +193 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +37 -0
  65. package/docs/setup.md +90 -92
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +318 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +77 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +141 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +121 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +31 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +111 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +137 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +119 -0
  183. package/examples/notes-service/src/notes/handlers.ts +39 -0
  184. package/examples/notes-service/src/notes/store.ts +84 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +288 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +27 -9
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -14
  211. package/dist/internal/media.d.mts +0 -8
  212. package/dist/internal/media.mjs +0 -26
  213. package/dist/internal/refusal.mjs +0 -67
  214. package/dist/internal/schema-errors.mjs +0 -20
  215. package/dist/internal/tools.mjs +0 -44
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Effect, flow, Layer } from "effect";
3
+ import { HttpClient, HttpClientRequest } from "effect/http";
4
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
5
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
7
+ import { Tools } from "../src/Actions.js";
8
+ import { AdminHttp, Http } from "../src/Api.js";
9
+ import { memory } from "../src/memory.js";
10
+ import { adminRoutes, builders, publicRoutes } from "../src/routes.js";
11
+
12
+ // The host's surfaces, as routes.ts builds them, answering in memory: no port is opened. Two
13
+ // ports are two `Testing.layer`s, each a layer graph of its own, as two servers are. What they
14
+ // share is provided above both, as server.ts does: the builders, then the services.
15
+ const shared = builders.pipe(Layer.provideMerge(memory));
16
+
17
+ const onPublicPort = Effect.provide(Testing.layer(publicRoutes));
18
+
19
+ const onAdminPort = Effect.provide(Testing.layer(adminRoutes));
20
+
21
+ const ada = {
22
+ transformClient: HttpClient.mapRequest(
23
+ flow(HttpClientRequest.bearerToken("ada"), HttpClientRequest.setHeader("x-workspace", "acme")),
24
+ ),
25
+ };
26
+
27
+ const ops = {
28
+ transformClient: HttpClient.mapRequest(HttpClientRequest.setHeader("x-operator-key", "ops-key")),
29
+ };
30
+
31
+ const post = (path: string) =>
32
+ HttpClient.execute(HttpClientRequest.post(path).pipe(HttpClientRequest.bodyJsonUnsafe({})));
33
+
34
+ describe("the public port", () => {
35
+ it.effect("answers the public action without a token and refuses the rest", () =>
36
+ Effect.gen(function* () {
37
+ const anyone = yield* ActionHttp.client(Http);
38
+
39
+ expect(yield* anyone.getStatus()).toMatchObject({ service: "tracker", workspaces: 2 });
40
+ expect(yield* Effect.flip(anyone.whoAmI())).toBeInstanceOf(Action.Unauthenticated);
41
+ }).pipe(onPublicPort, Effect.provide(shared)),
42
+ );
43
+
44
+ it.effect("lists the tools to a signed-out MCP client, the unlisted action left out", () =>
45
+ Effect.gen(function* () {
46
+ const listing = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
47
+ const text = yield* listing.text;
48
+
49
+ expect(listing.status).toBe(200);
50
+ expect(text).toContain('"name":"createIssue"');
51
+ expect(text).not.toContain('"name":"attachImage"');
52
+ }).pipe(onPublicPort, Effect.provide(shared)),
53
+ );
54
+
55
+ it.effect("serves one store to both surfaces: an issue filed over HTTP is read as a tool", () =>
56
+ Effect.gen(function* () {
57
+ const http = yield* ActionHttp.client(Http, ada);
58
+ const mcp = yield* Testing.mcpClient(Tools, ada);
59
+ const issue = yield* http.createIssue({ title: "Crash", body: "On save." });
60
+
61
+ expect(yield* mcp.getIssue({ id: issue.id })).toEqual(issue);
62
+ }).pipe(onPublicPort, Effect.provide(shared)),
63
+ );
64
+
65
+ it.effect("refuses, at the MCP endpoint, a page of this host that is not on its list", () =>
66
+ Effect.gen(function* () {
67
+ // The request policy lets both through: one is the request's own origin, one is listed.
68
+ const from = (url: string, origin: string) =>
69
+ HttpClient.execute(Testing.mcpRequest("tools/list", {}, { url, headers: { origin } }));
70
+
71
+ const listed = yield* from("/mcp", "http://localhost:6274");
72
+ const sameHost = yield* from("http://127.0.0.1/mcp", "http://127.0.0.1");
73
+
74
+ expect([listed.status, sameHost.status]).toEqual([200, 403]);
75
+ }).pipe(onPublicPort, Effect.provide(shared)),
76
+ );
77
+ });
78
+
79
+ describe("the two ports", () => {
80
+ it.effect("serve each audience its own routes, and none of the other's", () =>
81
+ Effect.gen(function* () {
82
+ const admin = yield* post("/admin/listWorkspaces").pipe(onPublicPort);
83
+ const member = yield* post("/api/v1/whoAmI").pipe(onAdminPort);
84
+
85
+ expect([admin.status, member.status]).toEqual([404, 404]);
86
+ }).pipe(Effect.provide(shared)),
87
+ );
88
+
89
+ it.effect("run a builder once for an implementation both serve", () =>
90
+ Effect.gen(function* () {
91
+ const onPublic = yield* Effect.flatMap(ActionHttp.client(Http), (client) =>
92
+ client.getStatus(),
93
+ ).pipe(onPublicPort);
94
+
95
+ const onAdmin = yield* Effect.flatMap(ActionHttp.client(AdminHttp), (client) =>
96
+ client.getStatus(),
97
+ ).pipe(onAdminPort);
98
+
99
+ // `status`'s builder names the start it ran in. With `builders` above both ports it ran
100
+ // once. Provide `memory` alone instead, and each port runs it: two instances.
101
+ expect(onAdmin.instance).toBe(onPublic.instance);
102
+ }).pipe(Effect.provide(shared)),
103
+ );
104
+
105
+ it.effect("share their services: a workspace suspended on one is refused on the other", () =>
106
+ Effect.gen(function* () {
107
+ const whoAmI = Effect.flatMap(ActionHttp.client(Http, ada), (client) => client.whoAmI());
108
+
109
+ expect(yield* whoAmI.pipe(onPublicPort)).toMatchObject({ id: "ada" });
110
+
111
+ const suspended = yield* Effect.flatMap(ActionHttp.client(AdminHttp, ops), (client) =>
112
+ client.suspendWorkspace({ workspace: "acme" }),
113
+ ).pipe(onAdminPort);
114
+
115
+ expect(suspended).toEqual({ slug: "acme", name: "Acme", suspended: true });
116
+
117
+ const refused = yield* Effect.flip(whoAmI).pipe(onPublicPort);
118
+
119
+ expect(refused).toBeInstanceOf(Action.Forbidden);
120
+ expect(refused.message).toBe("This workspace is suspended.");
121
+ }).pipe(Effect.provide(shared)),
122
+ );
123
+ });
@@ -0,0 +1,40 @@
1
+ import { Effect, Layer, Ref, Stream } from "effect";
2
+ import { AiError, LanguageModel, type Response } from "effect/ai";
3
+
4
+ type Reply = ReadonlyArray<Response.PartEncoded>;
5
+
6
+ // A model for a test: it answers its first request with the first reply it was given, its
7
+ // second with the second, whatever the prompt says. A reply is the parts a provider sends, or
8
+ // an Effect that makes them: `down` to fail as a provider that is down does, or one that waits.
9
+ export const scripted = (
10
+ ...replies: ReadonlyArray<Reply | Effect.Effect<Reply, AiError.AiError>>
11
+ ) =>
12
+ Layer.effect(
13
+ LanguageModel.LanguageModel,
14
+ Effect.gen(function* () {
15
+ const answered = yield* Ref.make(0);
16
+
17
+ return yield* LanguageModel.make({
18
+ generateText: () =>
19
+ Effect.flatMap(
20
+ Ref.getAndUpdate(answered, (count) => count + 1),
21
+ (count) => {
22
+ const reply = replies[count] ?? [];
23
+
24
+ return Effect.isEffect(reply)
25
+ ? Effect.map(reply, (parts) => [...parts])
26
+ : Effect.succeed([...reply]);
27
+ },
28
+ ),
29
+ streamText: () => Stream.empty,
30
+ });
31
+ }),
32
+ );
33
+
34
+ export const down: Effect.Effect<never, AiError.AiError> = Effect.fail(
35
+ AiError.make({
36
+ module: "scripted",
37
+ method: "generateText",
38
+ reason: new AiError.UnknownError({ description: "The provider is down." }),
39
+ }),
40
+ );
@@ -0,0 +1,107 @@
1
+ # live-rpc
2
+
3
+ A chat served as Effect RPC over a WebSocket: one connection per page, many calls on it.
4
+ Members post, read and delete messages in the rooms they belong to; anyone may list the rooms.
5
+ Each message on the socket carries its own token and names its room, so a socket has no
6
+ identity of its own. A message naming no room is about the default one, `lobby`.
7
+
8
+ Start here if a client keeps a connection open and calls often, or if the application already
9
+ speaks Effect RPC. For a JSON API or MCP tools, start from [notes-service](../notes-service/).
10
+ Actions are unary, so nothing streams: the page reads again, passing the last id it holds.
11
+
12
+ ## Run it
13
+
14
+ From the repository root, after `vp install` (from an installed package, see
15
+ [Run one](../README.md#run-one)):
16
+
17
+ ```sh
18
+ node --import tsx examples/live-rpc/src/server.ts # 127.0.0.1:3000, HOST and PORT to change
19
+ node --import tsx examples/live-rpc/src/client.ts # CHAT_URL to change, ws://127.0.0.1:3000/rpc
20
+
21
+ vp test examples/live-rpc
22
+ ```
23
+
24
+ `client.ts` runs the browser's client from Node: two members on one socket, then each failure
25
+ a page handles. `browser.ts` is imported by a page, not run: `openChat` for a page that is an
26
+ Effect program, `connect(url)` for one that calls with promises. The tokens are fixed names (`ada`,
27
+ `grace`) and the store is memory: replace both before deploying anything.
28
+
29
+ ## When you copy this
30
+
31
+ Three choices here show a feature. Decide each for your application before keeping it:
32
+
33
+ - **`listRooms` is public** to show a public rpc beside protected ones. It tells a signed-out
34
+ caller every room's name.
35
+ - **A message naming no room is about `lobby`**, to show a default in request context. Yours may
36
+ refuse instead.
37
+ - **The room is a header, not an input**, because `authorize` sees the action and not its input:
38
+ the membership rule reads the room from request context. With the room in the input, the
39
+ check belongs in the store, never in a helper each handler must remember to call: the store's
40
+ method takes the member and refuses, as `MessageStore.remove` in `src/chat/store.ts` takes the
41
+ author and refuses with `NotAuthor`.
42
+
43
+ ## Layout
44
+
45
+ ```text
46
+ src/
47
+ ├── chat/
48
+ │ ├── Chat.ts contract: schemas, errors, actions, the room header and its default
49
+ │ ├── store.ts MessageStore, the service the handlers call, and its Layer
50
+ │ ├── room.ts CurrentRoom, and the RPC middleware providing it per message
51
+ │ ├── authorize.ts the one authorization rule: a member of the room
52
+ │ └── handlers.ts Action.implement: contract to store
53
+ ├── identity/
54
+ │ ├── Identity.ts contract: CurrentMember and the Login descriptor
55
+ │ ├── members.ts the demo members
56
+ │ └── verifier.ts Authentication.layer: tokens to members, server only
57
+ ├── Rpc.ts the RPC binding of every action
58
+ ├── routes.ts the rpcs, authenticated and with their middleware, not yet on a protocol
59
+ ├── server.ts entrypoint: the rpcs over a WebSocket at /rpc, its messages bounded
60
+ ├── browser.ts client module: the connection and the chat, as Effects and as promises
61
+ └── client.ts entrypoint: that client, run from Node
62
+ test/
63
+ ├── chat/ in process, with Action.client
64
+ ├── routes.test.ts over RPC in memory, with Testing.layer
65
+ ├── browser.test.ts the page's client against the same rpcs
66
+ └── server.test.ts the socket itself: a listening port, connect(url), the bound on a message
67
+ ```
68
+
69
+ - **The PascalCase files are what a page imports.** `Chat.ts`, `Identity.ts` and `Rpc.ts` import
70
+ no server code. `browser.ts` imports them, the library's `ActionRpc`, and `effect`,
71
+ `effect/rpc` and `effect/socket`, nothing else, so it bundles for a browser.
72
+ - **A request-time service is never a startup service.** `CurrentMember` comes from
73
+ authentication and `CurrentRoom` from middleware, both per message. Neither has a Layer, and
74
+ no entrypoint provides one: a value provided at startup would serve every caller as one
75
+ member in one room.
76
+ - **The entrypoint chooses the protocol.** `routes.ts` exports the rpcs without one. `server.ts`
77
+ gives them a WebSocket, its only route. `browser.ts` leaves the WebSocket constructor to its
78
+ host: a page provides the browser's, `client.ts` provides Node's.
79
+ - **The socket belongs to the Layer providing it.** `connection(url)` opens it when it is built
80
+ and closes it when it is released: provide it once around the page's program, or keep it in
81
+ a `ManagedRuntime`, as `connect(url)` does. The scope `openChat` needs holds only the client.
82
+ - **One test file opens a port.** `Testing.layer` answers HTTP requests and cannot upgrade a
83
+ WebSocket, so `routes.test.ts` and `browser.test.ts` give the same rpcs Effect's HTTP protocol,
84
+ in memory. `server.test.ts` covers what that leaves out: it serves them over a WebSocket on a
85
+ port the system picks, calls with `connect(url)`, and sends a message over the server's bound.
86
+
87
+ ## Where to look
88
+
89
+ | To see | Read |
90
+ | ---------------------------------------------------------------------- | ----------------------------------------------- |
91
+ | An RPC binding naming its authentication descriptor | `src/Rpc.ts` |
92
+ | The rpcs on Effect's `RpcServer`, before a protocol is chosen | `src/routes.ts` |
93
+ | Serving them over a WebSocket, speaking JSON | `src/server.ts` |
94
+ | The same rpcs over Effect's HTTP protocol, for a test | `test/routes.test.ts` |
95
+ | A verifier that runs for every message, not once per connection | `src/identity/verifier.ts` |
96
+ | Native `RpcMiddleware` providing a per-request service, with a default | `src/chat/room.ts` |
97
+ | Handlers reading that service, and the rule that reads it | `src/chat/handlers.ts`, `src/chat/authorize.ts` |
98
+ | A public rpc beside protected ones | `src/chat/Chat.ts` |
99
+ | A socket client sending its token and room on each message | `src/browser.ts` |
100
+ | Who owns the socket, and how a promise page keeps it open | `src/browser.ts` |
101
+ | A declared error and the built-in refusals handled by tag | `src/browser.ts` |
102
+ | Running that client from Node, two callers on one socket | `src/client.ts` |
103
+ | Testing handlers and authorization with no transport | `test/chat/chat.test.ts` |
104
+ | Testing rpcs, authentication and middleware in memory | `test/routes.test.ts` |
105
+ | The binding's native `RpcGroup` called by Effect's own client | `test/routes.test.ts` |
106
+ | Testing the client's handling of each error and refusal | `test/browser.test.ts` |
107
+ | Testing over a real WebSocket, and a message over `maxPayload` | `test/server.test.ts` |
@@ -0,0 +1,8 @@
1
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
2
+ import { ListRooms, Messages } from "./chat/Chat.js";
3
+ import { Login } from "./identity/Identity.js";
4
+
5
+ // The RPC binding: one native rpc per action, named after it, the protected ones requiring
6
+ // `Login`. It is plain data and imports no server code, so the server and a browser page both
7
+ // import this one value. `Rpc.group` is the native `RpcGroup`, for Effect's own `RpcClient.make`.
8
+ export const Rpc = ActionRpc.make([ListRooms, ...Messages], { authentication: Login });
@@ -0,0 +1,98 @@
1
+ import { Context, Effect, Layer, ManagedRuntime } from "effect";
2
+ import { RpcClient, RpcSerialization } from "effect/rpc";
3
+ import { Socket } from "effect/socket";
4
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
5
+ import { roomHeader } from "./chat/Chat.js";
6
+ import { Rpc } from "./Rpc.js";
7
+
8
+ // Client module: what a web page imports. It holds the binding and nothing of the server, so a
9
+ // bundler gets the contracts and Effect's RPC client, no handler and nothing of Node.
10
+
11
+ // Effect's own client protocol: one WebSocket, speaking the server's JSON. This layer owns
12
+ // the socket: it opens when the layer is built and closes when the layer is released, so
13
+ // provide it once, around the page's whole program, never around one call. Which WebSocket is
14
+ // left to the host: a page provides `Socket.layerWebSocketConstructorGlobal`, the browser's
15
+ // own, and client.ts provides Node's.
16
+ export const connection = (url: string) =>
17
+ RpcClient.layerProtocolSocket().pipe(
18
+ Layer.provide(Socket.layerWebSocket(url)),
19
+ Layer.provide(RpcSerialization.layerJson),
20
+ );
21
+
22
+ // The chat as a page uses it, on the connection provided around it. The scope it needs holds
23
+ // only the typed client built on that connection, not the socket.
24
+ export const openChat = Effect.gen(function* () {
25
+ // One typed method per action of the binding, all on the one connection.
26
+ const client = yield* ActionRpc.client(Rpc);
27
+
28
+ return {
29
+ // The public rpc: no token.
30
+ rooms: Effect.map(client.listRooms(), ({ rooms }) => rooms),
31
+
32
+ room: (name: string, token: string) => {
33
+ // A browser WebSocket sets no header when it connects, so the token travels on each
34
+ // message, and the room beside it. A refreshed token needs no new socket.
35
+ const here = RpcClient.withHeaders({ authorization: `Bearer ${token}`, [roomHeader]: name });
36
+
37
+ return {
38
+ // Called again and again while the page is open, with the last id it holds. A token
39
+ // may expire under a socket that stays up, so the refusals are handled here. Each is
40
+ // a typed failure matched by its tag, and only that call fails: the socket stays open.
41
+ read: (after: number) =>
42
+ client.readMessages({ after }).pipe(
43
+ here,
44
+ Effect.map(({ messages }) => ({ access: "member" as const, messages })),
45
+ Effect.catchTags({
46
+ Unauthenticated: () =>
47
+ Effect.succeed({ access: "signed-out" as const, messages: [] }),
48
+ Forbidden: () => Effect.succeed({ access: "not-a-member" as const, messages: [] }),
49
+ }),
50
+ ),
51
+
52
+ post: (text: string) => client.postMessage({ text }).pipe(here),
53
+
54
+ // A declared error arrives as the contract's class. A message someone else already
55
+ // removed is gone, which is what the page asked for.
56
+ remove: (id: number) =>
57
+ client.deleteMessage({ id }).pipe(
58
+ here,
59
+ Effect.as("removed" as const),
60
+ Effect.catchTags({
61
+ MessageNotFound: () => Effect.succeed("removed" as const),
62
+ NotAuthor: () => Effect.succeed("not-yours" as const),
63
+ }),
64
+ ),
65
+ };
66
+ },
67
+ };
68
+ });
69
+
70
+ // The chat for a page that is not one Effect program: the same client, each call a promise.
71
+ // A `ManagedRuntime` keeps the protocol's layer, and so the socket, between calls. It builds
72
+ // the layer on the first call, which is when the socket opens, and `close` releases it. A
73
+ // failure `openChat` does not handle, such as `InvalidInput`, rejects the promise.
74
+ class Chat extends Context.Service<Chat, Effect.Success<typeof openChat>>()("live-rpc/Chat") {}
75
+
76
+ export const chatOver = <E>(protocol: Layer.Layer<RpcClient.Protocol, E>) => {
77
+ const runtime = ManagedRuntime.make(Layer.effect(Chat, openChat).pipe(Layer.provide(protocol)));
78
+
79
+ const call = <A, E2>(use: (chat: Chat["Service"]) => Effect.Effect<A, E2>) =>
80
+ runtime.runPromise(Effect.flatMap(Chat, use));
81
+
82
+ return {
83
+ rooms: () => call((chat) => chat.rooms),
84
+
85
+ room: (name: string, token: string) => ({
86
+ read: (after: number) => call((chat) => chat.room(name, token).read(after)),
87
+ post: (text: string) => call((chat) => chat.room(name, token).post(text)),
88
+ remove: (id: number) => call((chat) => chat.room(name, token).remove(id)),
89
+ }),
90
+
91
+ close: () => runtime.dispose(),
92
+ };
93
+ };
94
+
95
+ // What a page calls: `const chat = connect("wss://chat.example/rpc")`, on the browser's own
96
+ // WebSocket, then `await chat.room("lobby", token).post("Hello")`.
97
+ export const connect = (url: string) =>
98
+ chatOver(connection(url).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)));
@@ -0,0 +1,77 @@
1
+ import { Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { CurrentMember } from "../identity/Identity.js";
4
+
5
+ // A message's `id` counts up within its room, so a client asks for what came after the last
6
+ // one it holds.
7
+ export const Message = Schema.Struct({
8
+ id: Schema.Int,
9
+ author: Schema.String,
10
+ text: Schema.String,
11
+ });
12
+
13
+ export type Message = typeof Message.Type;
14
+
15
+ // A declared error is part of the contract: every client decodes it as this class. Its status
16
+ // is for a JSON binding, should these actions be served as one too: an rpc carries no status,
17
+ // and an error without one answers 422 over HTTP.
18
+ export class MessageNotFound extends Schema.TaggedError<MessageNotFound>()(
19
+ "MessageNotFound",
20
+ { id: Schema.Int },
21
+ { httpApiStatus: 404 },
22
+ ) {}
23
+
24
+ // A refusal carries what the caller sent and no more: who wrote the message is for the actions
25
+ // that read it, under their own rule.
26
+ export class NotAuthor extends Schema.TaggedError<NotAuthor>()(
27
+ "NotAuthor",
28
+ { id: Schema.Int },
29
+ { httpApiStatus: 403 },
30
+ ) {}
31
+
32
+ // Which room a call is about is not an input of any action: it travels beside the call, in
33
+ // this header, as the token does. The client sets it; on the server, room.ts reads it. A call
34
+ // without the header is about the default room, so `listRooms`, which is about none, sends none.
35
+ export const roomHeader = "x-room";
36
+
37
+ export const defaultRoom = "lobby";
38
+
39
+ // Public: `caller: Action.Anyone`. A page shows the rooms before anyone has signed in.
40
+ export const ListRooms = Action.make("listRooms", {
41
+ description: "List the rooms someone has posted in, and how many messages each holds.",
42
+ success: {
43
+ rooms: Schema.Array(Schema.Struct({ name: Schema.String, messages: Schema.Int })),
44
+ },
45
+ readOnly: true,
46
+ caller: Action.Anyone,
47
+ });
48
+
49
+ const signedIn = { caller: CurrentMember } as const;
50
+
51
+ // Actions are unary: nothing streams. A page keeps up by calling this again on its open
52
+ // socket, passing the last id it has seen.
53
+ export const ReadMessages = Action.make("readMessages", {
54
+ ...signedIn,
55
+ description: "Read the room's messages after the given id; 0 reads them all.",
56
+ input: { after: Schema.Int },
57
+ success: { messages: Schema.Array(Message) },
58
+ readOnly: true,
59
+ });
60
+
61
+ export const PostMessage = Action.make("postMessage", {
62
+ ...signedIn,
63
+ description: "Post a message to the room.",
64
+ input: { text: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)) },
65
+ success: Message,
66
+ readOnly: false,
67
+ });
68
+
69
+ export const DeleteMessage = Action.make("deleteMessage", {
70
+ ...signedIn,
71
+ description: "Delete one of your own messages from the room.",
72
+ input: { id: Schema.Int },
73
+ error: [MessageNotFound, NotAuthor],
74
+ readOnly: false,
75
+ });
76
+
77
+ export const Messages = [ReadMessages, PostMessage, DeleteMessage] as const;
@@ -0,0 +1,16 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { CurrentMember } from "../identity/Identity.js";
4
+ import { CurrentRoom } from "./room.js";
5
+
6
+ // The one rule: a member reads and writes only the rooms they belong to. It runs before every
7
+ // protected handler, on every surface, and reads two request-time services: the caller, from
8
+ // authentication, and the room, from the middleware.
9
+ export const authorize = Effect.fn("authorize")(function* () {
10
+ const member = yield* CurrentMember;
11
+ const room = yield* CurrentRoom;
12
+
13
+ if (!member.rooms.includes(room)) {
14
+ return yield* new Action.Forbidden({ message: `Not a member of ${room}.` });
15
+ }
16
+ });
@@ -0,0 +1,50 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { CurrentMember } from "../identity/Identity.js";
4
+ import { authorize } from "./authorize.js";
5
+ import { ListRooms, Messages } from "./Chat.js";
6
+ import { CurrentRoom } from "./room.js";
7
+ import { MessageStore } from "./store.js";
8
+
9
+ // A public action takes no `authorize`: nothing would run it.
10
+ export const rooms = Action.implement(
11
+ ListRooms,
12
+ Effect.gen(function* () {
13
+ const store = yield* MessageStore;
14
+
15
+ return () => Effect.map(store.rooms, (found) => ({ rooms: found }));
16
+ }),
17
+ );
18
+
19
+ // The builder runs once, at startup: it is where a handler gets its startup services. The
20
+ // caller and the room are read per call, inside each handler, because every message has its own.
21
+ export const messages = Action.implement(
22
+ Messages,
23
+ Effect.gen(function* () {
24
+ const store = yield* MessageStore;
25
+
26
+ return {
27
+ readMessages: ({ after }) =>
28
+ Effect.gen(function* () {
29
+ const room = yield* CurrentRoom;
30
+
31
+ return { messages: yield* store.read(room, after) };
32
+ }),
33
+ postMessage: ({ text }) =>
34
+ Effect.gen(function* () {
35
+ const room = yield* CurrentRoom;
36
+ const member = yield* CurrentMember;
37
+
38
+ return yield* store.post(room, member.id, text);
39
+ }),
40
+ deleteMessage: ({ id }) =>
41
+ Effect.gen(function* () {
42
+ const room = yield* CurrentRoom;
43
+ const member = yield* CurrentMember;
44
+
45
+ return yield* store.remove(room, member.id, id);
46
+ }),
47
+ };
48
+ }),
49
+ { authorize },
50
+ );
@@ -0,0 +1,23 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import { RpcMiddleware } from "effect/rpc";
3
+ import { defaultRoom, roomHeader } from "./Chat.js";
4
+
5
+ /**
6
+ * The room a message is about. A request-time service: handlers and `authorize` read it per
7
+ * call, and it has no Layer, because no one value of it is right for a whole server.
8
+ */
9
+ export class CurrentRoom extends Context.Service<CurrentRoom, string>()("live-rpc/CurrentRoom") {}
10
+
11
+ // Native Effect RPC middleware, declared as providing `CurrentRoom`. Listed in
12
+ // `ActionRpc.layer`'s `middleware`, it runs around every rpc, once per message.
13
+ export class ResolveRoom extends RpcMiddleware.Service<ResolveRoom, { provides: CurrentRoom }>()(
14
+ "live-rpc/ResolveRoom",
15
+ ) {}
16
+
17
+ // The middleware's implementation. It gets the message's headers, which over a WebSocket are
18
+ // the upgrade request's merged with the message's own, and runs the rest of the call with the
19
+ // room they name, or the default room when they name none. It refuses nothing: whether the
20
+ // caller may be in that room is `authorize`'s rule.
21
+ export const resolveRoom = Layer.succeed(ResolveRoom, (call, { headers }) =>
22
+ Effect.provideService(call, CurrentRoom, headers[roomHeader] ?? defaultRoom),
23
+ );
@@ -0,0 +1,54 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import { type Message, MessageNotFound, NotAuthor } from "./Chat.js";
3
+
4
+ /**
5
+ * Where messages live, by room. That only its author deletes a message is checked here, in the
6
+ * data access, so no surface and no handler can skip it.
7
+ */
8
+ export class MessageStore extends Context.Service<
9
+ MessageStore,
10
+ {
11
+ readonly rooms: Effect.Effect<ReadonlyArray<{ name: string; messages: number }>>;
12
+ readonly read: (room: string, after: number) => Effect.Effect<ReadonlyArray<Message>>;
13
+ readonly post: (room: string, author: string, text: string) => Effect.Effect<Message>;
14
+ readonly remove: (
15
+ room: string,
16
+ author: string,
17
+ id: number,
18
+ ) => Effect.Effect<void, MessageNotFound | NotAuthor>;
19
+ }
20
+ >()("live-rpc/MessageStore") {
21
+ // DEMO ONLY: memory, lost when the process stops. A real store is another Layer of this
22
+ // service, chosen by the entrypoint.
23
+ static readonly layerMemory = Layer.sync(MessageStore, () => {
24
+ const rooms = new Map<string, { last: number; messages: ReadonlyArray<Message> }>();
25
+
26
+ const room = (name: string) => rooms.get(name) ?? { last: 0, messages: [] };
27
+
28
+ return MessageStore.of({
29
+ rooms: Effect.sync(() =>
30
+ Array.from(rooms, ([name, { messages }]) => ({ name, messages: messages.length })),
31
+ ),
32
+ read: (name, after) => Effect.sync(() => room(name).messages.filter(({ id }) => id > after)),
33
+ post: (name, author, text) =>
34
+ Effect.sync(() => {
35
+ const { last, messages } = room(name);
36
+ const message = { id: last + 1, author, text };
37
+
38
+ rooms.set(name, { last: message.id, messages: [...messages, message] });
39
+
40
+ return message;
41
+ }),
42
+ remove: Effect.fn("MessageStore.remove")(function* (name, author, id) {
43
+ const { last, messages } = room(name);
44
+ const message = messages.find((candidate) => candidate.id === id);
45
+
46
+ if (message === undefined) return yield* new MessageNotFound({ id });
47
+
48
+ if (message.author !== author) return yield* new NotAuthor({ id });
49
+
50
+ rooms.set(name, { last, messages: messages.filter((kept) => kept !== message) });
51
+ }),
52
+ });
53
+ });
54
+ }
@@ -0,0 +1,37 @@
1
+ import { NodeRuntime, NodeSocket } from "@effect/platform-node";
2
+ import { Config, Console, Effect, Layer } from "effect";
3
+ import { connection, openChat } from "./browser.js";
4
+
5
+ // Entrypoint: `node --import tsx examples/live-rpc/src/client.ts`, with the server running.
6
+ // The page's client, run from Node: the same `openChat`, given Node's WebSocket.
7
+ const visit = Effect.gen(function* () {
8
+ const chat = yield* openChat;
9
+
10
+ // Two members on the one socket: each call is whoever its token says.
11
+ const ada = chat.room("lobby", "ada");
12
+ const grace = chat.room("lobby", "grace");
13
+
14
+ yield* Console.log("rooms, before signing in:", yield* chat.rooms);
15
+
16
+ const posted = yield* ada.post("Deploying at noon.");
17
+
18
+ yield* Console.log("ada posts:", posted);
19
+ yield* Console.log("grace reads:", yield* grace.read(posted.id - 1));
20
+ yield* Console.log("grace removes it:", yield* grace.remove(posted.id));
21
+ yield* Console.log("ada removes it:", yield* ada.remove(posted.id));
22
+ yield* Console.log("grace reads ops:", yield* chat.room("ops", "grace").read(0));
23
+ yield* Console.log("an expired token reads:", yield* chat.room("lobby", "expired").read(0));
24
+ });
25
+
26
+ const main = Effect.gen(function* () {
27
+ const url = yield* Config.String("CHAT_URL").pipe(Config.withDefault("ws://127.0.0.1:3000/rpc"));
28
+
29
+ // The socket belongs to `connection(url)`: provided once around the whole visit, it opens
30
+ // for it and closes when this `Effect.provide` ends. `Effect.scoped` holds the client alone.
31
+ yield* visit.pipe(
32
+ Effect.scoped,
33
+ Effect.provide(connection(url).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor))),
34
+ );
35
+ });
36
+
37
+ NodeRuntime.runMain(main);
@@ -0,0 +1,19 @@
1
+ import { Context } from "effect";
2
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
+
4
+ export interface Member {
5
+ readonly id: string;
6
+ readonly rooms: ReadonlyArray<string>;
7
+ }
8
+
9
+ /**
10
+ * Who sent a message. A protected action declares it, `caller: CurrentMember`; authentication
11
+ * provides it for each message, and a test calling in process provides it itself.
12
+ */
13
+ export class CurrentMember extends Context.Service<CurrentMember, Member>()(
14
+ "live-rpc/CurrentMember",
15
+ ) {}
16
+
17
+ // How a remote caller proves it is CurrentMember: a bearer token, the default scheme. Clients
18
+ // import this descriptor with the binding; its verifier, in verifier.ts, stays on the server.
19
+ export const Login = Authentication.make("live-rpc.Login", CurrentMember);