@gjermundgaraba/effect-actions 0.11.0 → 0.12.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 +101 -40
  2. package/dist/Action.d.mts +29 -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 +5 -8
  13. package/dist/ActionToolkit.mjs +3 -3
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +28 -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 +17 -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 +206 -426
  46. package/docs/ActionCli-failures.md +102 -0
  47. package/docs/ActionCli.md +206 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +202 -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 +178 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +100 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +142 -392
  59. package/docs/CONTEXT.md +39 -24
  60. package/docs/README.md +71 -35
  61. package/docs/Testing-failures.md +20 -0
  62. package/docs/Testing.md +186 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +34 -0
  65. package/docs/setup.md +87 -91
  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 +317 -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 +146 -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 +109 -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 +41 -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 +75 -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 +136 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +119 -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 +30 -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 +108 -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 +89 -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 +91 -0
  183. package/examples/notes-service/src/notes/handlers.ts +33 -0
  184. package/examples/notes-service/src/notes/store.ts +73 -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 +134 -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 +23 -5
  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,103 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Array, Effect, Exit, References } from "effect";
3
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
4
+ import { CurrentUser } from "../../src/identity/Identity.js";
5
+ import { users } from "../../src/identity/users.js";
6
+ import { notes } from "../../src/notes/handlers.js";
7
+ import { NoteChanged, NoteNotFound } from "../../src/notes/Notes.js";
8
+ import { NoteStore } from "../../src/notes/store.js";
9
+
10
+ // What the implementation does, with no transport: `Action.client` runs the same decoding,
11
+ // `authorize` and handler a server runs. Each call names its caller, as a request would.
12
+ const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
13
+
14
+ describe("notes, in process", () => {
15
+ it.effect("keeps each user's notes their own", () =>
16
+ Effect.gen(function* () {
17
+ const client = yield* Action.client(notes);
18
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
19
+
20
+ expect(yield* client.listNotes().pipe(as("ada"))).toEqual({
21
+ notes: [{ id: note.id, title: "Plan" }],
22
+ });
23
+ expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
24
+
25
+ const missing = new NoteNotFound({ id: note.id });
26
+ const update = { id: note.id, body: "Mine now", revision: note.revision };
27
+
28
+ expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("grace")))).toEqual(
29
+ missing,
30
+ );
31
+ expect(yield* Effect.flip(client.updateNote(update).pipe(as("grace")))).toEqual(missing);
32
+ expect(yield* Effect.flip(client.deleteNote({ id: note.id }).pipe(as("grace")))).toEqual(
33
+ missing,
34
+ );
35
+ expect(yield* client.getNote({ id: note.id }).pipe(as("ada"))).toEqual(note);
36
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
37
+ );
38
+
39
+ it.effect("deletes a note for its owner", () =>
40
+ Effect.gen(function* () {
41
+ const client = yield* Action.client(notes);
42
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
43
+
44
+ yield* client.deleteNote({ id: note.id }).pipe(as("ada"));
45
+
46
+ expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("ada")))).toEqual(
47
+ new NoteNotFound({ id: note.id }),
48
+ );
49
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
50
+ );
51
+
52
+ it.effect("refuses an update of a revision that is no longer current", () =>
53
+ Effect.gen(function* () {
54
+ const client = yield* Action.client(notes);
55
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
56
+ const update = { id: note.id, body: "Final", revision: note.revision };
57
+
58
+ expect(yield* client.updateNote(update).pipe(as("ada"))).toMatchObject({ revision: 2 });
59
+ expect(yield* Effect.flip(client.updateNote(update).pipe(as("ada")))).toEqual(
60
+ new NoteChanged({ id: note.id, revision: 2 }),
61
+ );
62
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
63
+ );
64
+
65
+ it.effect("lets one of two concurrent updates of the same revision through", () =>
66
+ Effect.gen(function* () {
67
+ const client = yield* Action.client(notes);
68
+
69
+ // A fiber is set aside after every `steps` steps, and two that run at once then take
70
+ // turns. Each count sets the first update aside at another point of its work; under
71
+ // three, the two do not finish.
72
+ const succeeded = yield* Effect.forEach(Array.range(3, 60), (steps) =>
73
+ Effect.gen(function* () {
74
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
75
+
76
+ const update = (body: string) =>
77
+ Effect.exit(client.updateNote({ id: note.id, body, revision: 1 }).pipe(as("ada")));
78
+
79
+ const exits = yield* Effect.all([update("One"), update("Two")], {
80
+ concurrency: "unbounded",
81
+ }).pipe(Effect.provideService(References.MaxOpsBeforeYield, steps));
82
+
83
+ return exits.filter(Exit.isSuccess).length;
84
+ }),
85
+ );
86
+
87
+ expect(succeeded).toEqual(Array.range(3, 60).map(() => 1));
88
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
89
+ );
90
+
91
+ it.effect("refuses a write from a caller holding the read scope alone", () =>
92
+ Effect.gen(function* () {
93
+ const client = yield* Action.client(notes);
94
+ const create = client.createNote({ title: "Plan", body: "Draft" });
95
+ const refusal = yield* Effect.flip(create.pipe(as("reader")));
96
+
97
+ // `toEqual` on an error compares its fields and never its message, so the message is
98
+ // asserted on its own.
99
+ expect(refusal).toBeInstanceOf(Action.Forbidden);
100
+ expect(refusal.message).toBe("Requires notes:write.");
101
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
102
+ );
103
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Effect, 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 { Http } from "../src/Api.js";
8
+ import { Notes } from "../src/notes/Notes.js";
9
+ import { NoteStore } from "../src/notes/store.js";
10
+ import { routes } from "../src/routes.js";
11
+ import { GetStatus } from "../src/status/Status.js";
12
+
13
+ // What the wire adds: the real routes, behind their real authentication, answering in memory.
14
+ // No port is opened. The caller is the token each client sends, as in production.
15
+ const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
16
+
17
+ const as = (token: string) => ({
18
+ transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
19
+ });
20
+
21
+ describe("the service, over HTTP and MCP", () => {
22
+ it.effect("answers the public action without a token and refuses the rest", () =>
23
+ Effect.gen(function* () {
24
+ const anyone = yield* ActionHttp.client(Http);
25
+
26
+ expect(yield* anyone.getStatus()).toEqual({ service: "notes-service", notes: 0 });
27
+ expect(yield* Effect.flip(anyone.whoAmI())).toBeInstanceOf(Action.Unauthenticated);
28
+ }).pipe(Effect.provide(served)),
29
+ );
30
+
31
+ it.effect("refuses a token no user holds", () =>
32
+ Effect.gen(function* () {
33
+ const stranger = yield* ActionHttp.client(Http, as("mallory"));
34
+
35
+ const refusal = yield* Effect.flip(stranger.whoAmI());
36
+
37
+ expect(refusal).toBeInstanceOf(Action.Unauthenticated);
38
+ expect(refusal.message).toBe("Unknown demo token.");
39
+ }).pipe(Effect.provide(served)),
40
+ );
41
+
42
+ it.effect("serves the host's own routes beside the actions'", () =>
43
+ Effect.gen(function* () {
44
+ const health = yield* HttpClient.get("/healthz");
45
+ const openApi = yield* HttpClient.get("/api/openapi.json");
46
+
47
+ expect(yield* health.text).toBe("ok");
48
+ expect(yield* openApi.text).toContain('"/api/createNote"');
49
+ }).pipe(Effect.provide(served)),
50
+ );
51
+
52
+ it.effect("names the scope a caller lacks, so an OAuth client can ask for it", () =>
53
+ Effect.gen(function* () {
54
+ const reader = yield* ActionHttp.client(Http, as("reader"));
55
+
56
+ expect(yield* reader.whoAmI()).toEqual({ id: "reader", scopes: ["notes:read"] });
57
+
58
+ const refusal = yield* Effect.flip(reader.createNote({ title: "Plan", body: "" }));
59
+
60
+ expect(refusal).toBeInstanceOf(Action.Forbidden);
61
+ expect(refusal).toHaveProperty("scopes", ["notes:write"]);
62
+ }).pipe(Effect.provide(served)),
63
+ );
64
+
65
+ it.effect("serves one store to both surfaces: a note written over HTTP is read as a tool", () =>
66
+ Effect.gen(function* () {
67
+ const http = yield* ActionHttp.client(Http, as("ada"));
68
+ const mcp = yield* Testing.mcpClient([GetStatus, ...Notes], as("ada"));
69
+ const note = yield* http.createNote({ title: "Plan", body: "Draft" });
70
+
71
+ expect(yield* mcp.getNote({ id: note.id })).toEqual(note);
72
+ expect(yield* mcp.getStatus()).toEqual({ service: "notes-service", notes: 1 });
73
+ }).pipe(Effect.provide(served)),
74
+ );
75
+
76
+ it.effect("lists every tool to a signed-out MCP client, as one of them is public", () =>
77
+ Effect.gen(function* () {
78
+ const response = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
79
+
80
+ expect(response.status).toBe(200);
81
+ expect(yield* response.text).toContain('"name":"createNote"');
82
+ }).pipe(Effect.provide(served)),
83
+ );
84
+ });
@@ -0,0 +1,31 @@
1
+ # quickstart
2
+
3
+ The smallest complete program: one action, served as a JSON route and as an MCP tool, called
4
+ with its typed client. It is the skeleton of [notes-service](../notes-service/), which adds
5
+ authentication, authorization, declared errors, a store, a CLI and a browser client to the same
6
+ layout.
7
+
8
+ ## Run it
9
+
10
+ From the repository root, after `vp install` (from an installed package, see
11
+ [Run one](../README.md#run-one)):
12
+
13
+ ```sh
14
+ node --import tsx examples/quickstart/src/server.ts # 127.0.0.1:3000
15
+ node --import tsx examples/quickstart/src/client.ts # Hello, Ada!
16
+ vp test examples/quickstart
17
+ ```
18
+
19
+ ## Layout
20
+
21
+ ```text
22
+ src/
23
+ ├── greeting/
24
+ │ ├── Greeting.ts the contract
25
+ │ └── handlers.ts its implementation
26
+ ├── Api.ts the HTTP binding, shared by server and client
27
+ ├── routes.ts the surfaces: POST /api/greet and /mcp
28
+ ├── server.ts entrypoint
29
+ └── client.ts entrypoint
30
+ test/greeting/ the routes, called in memory
31
+ ```
@@ -0,0 +1,5 @@
1
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
2
+ import { Greet } from "./greeting/Greeting.js";
3
+
4
+ // The HTTP binding: `POST /api/greet`. The server serves it and the client calls it.
5
+ export const Http = ActionHttp.make([Greet]);
@@ -0,0 +1,13 @@
1
+ import { NodeRuntime } from "@effect/platform-node";
2
+ import { Console, Effect } from "effect";
3
+ import { FetchHttpClient } from "effect/http";
4
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
5
+ import { Http } from "./Api.js";
6
+
7
+ // Entrypoint: `node --import tsx examples/quickstart/src/client.ts`, with the server running.
8
+ // One typed method per action of the binding.
9
+ Effect.gen(function* () {
10
+ const client = yield* ActionHttp.client(Http, { baseUrl: "http://127.0.0.1:3000" });
11
+
12
+ yield* Console.log(yield* client.greet({ name: "Ada" }));
13
+ }).pipe(Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain);
@@ -0,0 +1,12 @@
1
+ import { Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+
4
+ // A contract: what the action takes and answers, whether it changes anything, and who may
5
+ // call it. It imports no server code, so any client can import it, a browser page included.
6
+ export const Greet = Action.make("greet", {
7
+ description: "Greet someone by name.",
8
+ input: { name: Schema.String },
9
+ success: Schema.String,
10
+ readOnly: true,
11
+ caller: Action.Anyone,
12
+ });
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import { Greet } from "./Greeting.js";
4
+
5
+ // The implementation: the contract bound to its handler. Every surface takes this one value.
6
+ export const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
@@ -0,0 +1,11 @@
1
+ import { Layer } from "effect";
2
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
3
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
4
+ import { Http } from "./Api.js";
5
+ import { greeting } from "./greeting/handlers.js";
6
+
7
+ // One implementation, two surfaces: a JSON route and an MCP tool at `/mcp`.
8
+ export const routes = Layer.mergeAll(
9
+ ActionHttp.layer(Http, greeting),
10
+ ActionMcp.layerHttp(greeting, { name: "greetings", version: "1.0.0" }),
11
+ );
@@ -0,0 +1,16 @@
1
+ import { createServer } from "node:http";
2
+ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
3
+ import { Layer } from "effect";
4
+ import { HttpRouter } from "effect/http";
5
+ import { routes } from "./routes.js";
6
+
7
+ // Entrypoint: `node --import tsx examples/quickstart/src/server.ts`.
8
+ // A real server reads its port from config, `Config.Port("PORT")`, and limits request bodies:
9
+ // `Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1)))`.
10
+ // `127.0.0.1` serves this machine alone: a server others reach reads its host from config,
11
+ // `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
12
+ HttpRouter.serve(routes).pipe(
13
+ Layer.provide(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 3000 })),
14
+ Layer.launch,
15
+ NodeRuntime.runMain,
16
+ );
@@ -0,0 +1,18 @@
1
+ import { expect, it } from "@effect/vitest";
2
+ import { Effect } from "effect";
3
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
5
+ import { Http } from "../../src/Api.js";
6
+ import { Greet } from "../../src/greeting/Greeting.js";
7
+ import { routes } from "../../src/routes.js";
8
+
9
+ // The routes answer in memory: no port, and the same code the server runs.
10
+ it.effect("greets over HTTP and as an MCP tool", () =>
11
+ Effect.gen(function* () {
12
+ const http = yield* ActionHttp.client(Http);
13
+ const mcp = yield* Testing.mcpClient([Greet]);
14
+
15
+ expect(yield* http.greet({ name: "Ada" })).toBe("Hello, Ada!");
16
+ expect(yield* mcp.greet({ name: "Ada" })).toBe("Hello, Ada!");
17
+ }).pipe(Effect.provide(Testing.layer(routes))),
18
+ );
package/package.json CHANGED
@@ -1,7 +1,19 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions",
3
- "version": "0.11.0",
4
- "description": "Define Effect action contracts once and project them onto HTTP, MCP, native Toolkits, and CLIs.",
3
+ "version": "0.12.0",
4
+ "description": "Define Effect action contracts once and project them onto HTTP, MCP, Effect RPC, native Toolkits, and CLIs.",
5
+ "keywords": [
6
+ "ai-agents",
7
+ "cli",
8
+ "effect",
9
+ "effect-ts",
10
+ "http-api",
11
+ "mcp",
12
+ "mcp-server",
13
+ "model-context-protocol",
14
+ "rpc",
15
+ "typescript"
16
+ ],
5
17
  "homepage": "https://github.com/gjermundgaraba/effect-actions#readme",
6
18
  "bugs": {
7
19
  "url": "https://github.com/gjermundgaraba/effect-actions/issues"
@@ -11,9 +23,14 @@
11
23
  "type": "git",
12
24
  "url": "git+https://github.com/gjermundgaraba/effect-actions.git"
13
25
  },
26
+ "directories": {
27
+ "doc": "docs",
28
+ "example": "examples"
29
+ },
14
30
  "files": [
15
31
  "dist",
16
- "docs"
32
+ "docs",
33
+ "examples"
17
34
  ],
18
35
  "type": "module",
19
36
  "sideEffects": false,
@@ -22,6 +39,7 @@
22
39
  "./ActionCli": "./dist/ActionCli.mjs",
23
40
  "./ActionHttp": "./dist/ActionHttp.mjs",
24
41
  "./ActionMcp": "./dist/ActionMcp.mjs",
42
+ "./ActionRpc": "./dist/ActionRpc.mjs",
25
43
  "./ActionToolkit": "./dist/ActionToolkit.mjs",
26
44
  "./Authentication": "./dist/Authentication.mjs",
27
45
  "./Testing": "./dist/Testing.mjs",
@@ -65,8 +83,8 @@
65
83
  "dev": "vp pack --watch",
66
84
  "test": "vp test",
67
85
  "check": "vp check",
68
- "example": "node --import tsx examples/server.ts",
69
- "test:package": "vp run build && node scripts/test-package.mjs",
86
+ "example": "node --import tsx examples/notes-service/src/server.ts",
87
+ "test:package": "vp run build && node --import tsx scripts/test-package.ts",
70
88
  "docs:sync": "node --import tsx scripts/sync-skill.ts"
71
89
  }
72
90
  }
@@ -1,9 +0,0 @@
1
- import "../Action.mjs";
2
- import { SchemaAST } from "effect";
3
- //#region src/internal/actions.d.ts
4
- /**
5
- * The caller of a public action: anyone, signed in or not. Registered globally, so two copies
6
- * of the package agree on it, as they agree on a name.
7
- */
8
- export declare const Anyone: unique symbol;
9
- //#endregion
@@ -1,11 +0,0 @@
1
- import { Unauthenticated } from "./errors.mjs";
2
- import { Anyone } from "./actions.mjs";
3
- import { Effect, Option } from "effect";
4
- //#region src/internal/authentication.ts
5
- const assertAuthentication = (actions, auth) => {
6
- for (const action of actions) if (action.caller !== Anyone && (auth === void 0 || action.caller.key !== auth.identity.key)) throw new Error(`Protected action '${action.name}' requires its matching authentication descriptor`);
7
- };
8
- /** Remote promotion happens at tool entry, before shared dispatch, never in local calls. */
9
- const promote = (auth, run) => Effect.flatMap(Effect.serviceOption(auth["~verified"]), (actor) => Option.isSome(actor) ? Effect.provideService(run, auth.identity, actor.value) : Effect.fail(new Unauthenticated()));
10
- //#endregion
11
- export { assertAuthentication, promote };
@@ -1,12 +0,0 @@
1
- import { Effect, Schema } from "effect";
2
- //#region src/internal/call.ts
3
- /**
4
- * The input a method of `action` sends for the arguments it was called with: a given
5
- * argument as given, and none as the input `{}` decodes to, as a server decodes the `{}` it
6
- * receives. `Call` allows none only when `{}` is a valid encoded input, which is what this
7
- * decodes; decoding makes it one of the input's own values, such as an instance of an input
8
- * class, or fills its defaults.
9
- */
10
- const inputOf = (action, args) => args.length === 0 ? Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))({}) : Effect.succeed(args[0]);
11
- //#endregion
12
- export { inputOf };