@gjermundgaraba/effect-actions 0.18.2 → 0.20.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 (155) hide show
  1. package/README.md +20 -47
  2. package/dist/Action.d.mts +96 -49
  3. package/dist/Action.mjs +12 -16
  4. package/dist/ActionCli.d.mts +82 -44
  5. package/dist/ActionCli.mjs +54 -35
  6. package/dist/ActionHttp.d.mts +84 -45
  7. package/dist/ActionHttp.mjs +3 -4
  8. package/dist/ActionMcp.d.mts +54 -29
  9. package/dist/ActionMcp.mjs +33 -15
  10. package/dist/ActionRpc.d.mts +48 -37
  11. package/dist/ActionRpc.mjs +3 -4
  12. package/dist/ActionToolkit.d.mts +32 -15
  13. package/dist/ActionToolkit.mjs +11 -9
  14. package/dist/Authentication.d.mts +92 -42
  15. package/dist/Authentication.mjs +38 -37
  16. package/dist/Testing.d.mts +23 -27
  17. package/dist/Testing.mjs +1 -2
  18. package/dist/authentication/provider.d.mts +1 -6
  19. package/dist/authentication/provider.mjs +2 -3
  20. package/dist/authentication/refusal.mjs +3 -3
  21. package/dist/cli/commands.d.mts +34 -13
  22. package/dist/cli/commands.mjs +48 -24
  23. package/dist/contract/ast.mjs +1 -2
  24. package/dist/contract/call.d.mts +1 -3
  25. package/dist/contract/call.mjs +1 -2
  26. package/dist/contract/caller.d.mts +1 -3
  27. package/dist/contract/caller.mjs +2 -3
  28. package/dist/contract/declared.d.mts +0 -2
  29. package/dist/contract/declared.mjs +28 -16
  30. package/dist/contract/errors.d.mts +42 -19
  31. package/dist/contract/errors.mjs +43 -19
  32. package/dist/contract/hints.mjs +4 -4
  33. package/dist/contract/implementation.d.mts +7 -14
  34. package/dist/contract/implementation.mjs +31 -19
  35. package/dist/contract/media.d.mts +7 -5
  36. package/dist/contract/media.mjs +7 -4
  37. package/dist/contract/names.mjs +15 -14
  38. package/dist/contract/status.mjs +4 -12
  39. package/dist/http/client.d.mts +15 -6
  40. package/dist/http/client.mjs +2 -3
  41. package/dist/http/schema-errors.mjs +1 -2
  42. package/dist/mcp/client.d.mts +21 -10
  43. package/dist/mcp/client.mjs +8 -11
  44. package/dist/mcp/protocol.mjs +1 -2
  45. package/dist/stdio/console.mjs +1 -2
  46. package/dist/testing/memory.d.mts +1 -3
  47. package/dist/testing/memory.mjs +1 -2
  48. package/docs/Action-failures.md +6 -15
  49. package/docs/Action.md +159 -191
  50. package/docs/ActionCli-failures.md +12 -92
  51. package/docs/ActionCli.md +102 -156
  52. package/docs/ActionHttp-failures.md +14 -76
  53. package/docs/ActionHttp.md +150 -176
  54. package/docs/ActionMcp-failures.md +2 -22
  55. package/docs/ActionMcp.md +79 -170
  56. package/docs/ActionRpc-failures.md +3 -12
  57. package/docs/ActionRpc.md +183 -120
  58. package/docs/ActionToolkit-failures.md +3 -10
  59. package/docs/ActionToolkit.md +112 -109
  60. package/docs/Authentication-advanced.md +171 -108
  61. package/docs/Authentication-failures.md +11 -91
  62. package/docs/Authentication.md +114 -135
  63. package/docs/README.md +45 -30
  64. package/docs/Testing-failures.md +2 -6
  65. package/docs/Testing.md +126 -159
  66. package/docs/setup-failures.md +8 -32
  67. package/docs/setup.md +42 -38
  68. package/examples/README.md +6 -10
  69. package/examples/assistant/README.md +20 -38
  70. package/examples/assistant/src/agent.ts +2 -3
  71. package/examples/assistant/src/cli.ts +4 -9
  72. package/examples/assistant/src/commands.ts +6 -4
  73. package/examples/assistant/src/identity/authorize.ts +3 -3
  74. package/examples/assistant/src/tasks/Tasks.ts +1 -2
  75. package/examples/assistant/src/tasks/handlers.ts +3 -3
  76. package/examples/assistant/src/toolkit.ts +6 -8
  77. package/examples/assistant/test/commands.test.ts +16 -11
  78. package/examples/assistant/test/tasks/tasks.test.ts +0 -2
  79. package/examples/assistant/test/toolkit.test.ts +0 -1
  80. package/examples/kitchen-sink/README.md +30 -52
  81. package/examples/kitchen-sink/src/Actions.ts +2 -3
  82. package/examples/kitchen-sink/src/Api.ts +3 -4
  83. package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
  84. package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
  85. package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
  86. package/examples/kitchen-sink/src/agent.ts +1 -1
  87. package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
  88. package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
  89. package/examples/kitchen-sink/src/attachments/download.ts +5 -8
  90. package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
  91. package/examples/kitchen-sink/src/browser.ts +3 -4
  92. package/examples/kitchen-sink/src/cli.ts +6 -7
  93. package/examples/kitchen-sink/src/config.ts +2 -0
  94. package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
  95. package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
  96. package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
  97. package/examples/kitchen-sink/src/identity/quota.ts +1 -1
  98. package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
  99. package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
  100. package/examples/kitchen-sink/src/issues/guide.ts +2 -3
  101. package/examples/kitchen-sink/src/ops.ts +3 -6
  102. package/examples/kitchen-sink/src/request-policy.ts +8 -4
  103. package/examples/kitchen-sink/src/routes.ts +4 -6
  104. package/examples/kitchen-sink/src/seed.ts +0 -1
  105. package/examples/kitchen-sink/src/server.ts +11 -12
  106. package/examples/kitchen-sink/src/stdio.ts +3 -2
  107. package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
  108. package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
  109. package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
  110. package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
  111. package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
  112. package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
  113. package/examples/kitchen-sink/test/request-policy.test.ts +39 -3
  114. package/examples/kitchen-sink/test/routes.test.ts +3 -3
  115. package/examples/live-rpc/README.md +8 -49
  116. package/examples/live-rpc/src/browser.ts +8 -41
  117. package/examples/live-rpc/src/chat/Chat.ts +9 -3
  118. package/examples/live-rpc/src/chat/room.ts +1 -1
  119. package/examples/live-rpc/src/client.ts +2 -3
  120. package/examples/live-rpc/src/page.ts +34 -0
  121. package/examples/live-rpc/src/routes.ts +6 -7
  122. package/examples/live-rpc/src/server.ts +6 -4
  123. package/examples/live-rpc/test/browser.test.ts +6 -5
  124. package/examples/live-rpc/test/routes.test.ts +16 -15
  125. package/examples/live-rpc/test/server.test.ts +1 -1
  126. package/examples/mcp-tools/README.md +22 -45
  127. package/examples/mcp-tools/src/printers/Printers.ts +3 -7
  128. package/examples/mcp-tools/src/routes.ts +0 -7
  129. package/examples/mcp-tools/src/server.ts +1 -1
  130. package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
  131. package/examples/mcp-tools/test/routes.test.ts +2 -3
  132. package/examples/notes-service/README.md +9 -43
  133. package/examples/notes-service/src/cli.ts +0 -3
  134. package/examples/notes-service/src/commands.ts +7 -17
  135. package/examples/notes-service/src/identity/authorize.ts +2 -5
  136. package/examples/notes-service/src/identity/verifier.ts +3 -4
  137. package/examples/notes-service/src/notes/Notes.ts +2 -6
  138. package/examples/notes-service/src/notes/store.ts +5 -1
  139. package/examples/notes-service/src/routes.ts +6 -8
  140. package/examples/notes-service/src/server.ts +4 -3
  141. package/examples/notes-service/src/stdio.ts +4 -3
  142. package/examples/notes-service/test/commands.test.ts +1 -1
  143. package/examples/notes-service/test/notes/as.ts +6 -0
  144. package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
  145. package/examples/notes-service/test/notes/in-process.test.ts +50 -0
  146. package/examples/notes-service/test/notes/notes.test.ts +3 -42
  147. package/examples/notes-service/test/routes.test.ts +2 -2
  148. package/examples/quickstart/README.md +1 -1
  149. package/examples/quickstart/src/client.ts +1 -0
  150. package/examples/quickstart/src/server.ts +2 -4
  151. package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
  152. package/package.json +12 -16
  153. package/docs/Action-in-process.md +0 -93
  154. package/docs/CONTEXT.md +0 -46
  155. package/docs/guarantees.md +0 -44
package/docs/Testing.md CHANGED
@@ -4,207 +4,174 @@ Two ways to test, and neither opens a port. `Action.client` calls an implementat
4
4
  what its handlers and its `authorize` do. `Testing.layer` answers the real routes in memory,
5
5
  through Effect's own `HttpClient`: what a surface adds, such as authentication, statuses,
6
6
  challenges and tool results as sent. The examples put a domain's tests in `test/<domain>/` and
7
- tests of the surfaces beside them, in `it.effect` from `@effect/vitest`, which supplies the
8
- `Scope` that `Action.client` and `ActionRpc.client` require, a `TestClock` and a `TestConsole`
9
- (both imported from `effect/testing`).
7
+ tests of the surfaces beside them, in `it.effect` from `@effect/vitest`, which supplies a
8
+ `Scope`, a `TestClock` and a `TestConsole` (both imported from `effect/testing`).
10
9
 
11
- Read this page alone, in one tool call; if its output says it was truncated, read it again alone.
12
- When something fails, search [Testing-failures.md](Testing-failures.md). Reading order:
13
- [README.md](README.md).
14
-
15
- ## API
16
-
17
- Import `@gjermundgaraba/effect-actions/Testing`.
18
-
19
- | API | Purpose |
20
- | ---------------- | ---------------------------------------------------------------------------- |
21
- | `layer(routes)` | A `Layer<HttpClient>` answering requests with `routes` in memory. |
22
- | `layer(handler)` | A `Layer<HttpClient>` answering requests with a web handler the test serves. |
23
-
24
- MCP tools are tested with `ActionMcp.client` and `ActionMcp.request`
25
- ([Test MCP tools](#test-mcp-tools)).
10
+ A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search [Testing-failures.md](Testing-failures.md). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`: each declaration's JSDoc carries its rules and gotchas, and this page only what an example cannot show.
26
11
 
27
12
  ## Test an implementation in process
28
13
 
29
14
  Acquire `Action.client(implementation)` and call its methods
30
- ([Action-in-process.md](Action-in-process.md#call-actions-in-process)). A call runs the decoding,
31
- the `authorize` and the handler a server runs, but no authentication and no middleware. Provide
32
- the startup services around the test, as an entrypoint does, and what a request would carry, its
33
- caller first, around each call:
34
-
35
- ```ts
15
+ ([Action.md](Action.md#call-actions-in-process)), as
16
+ [notes-service/test/notes/in-process.test.ts](../examples/notes-service/test/notes/in-process.test.ts)
17
+ does. The rules are on `Action.client`.
18
+
19
+ ```ts example=notes-service/test/notes/in-process.test.ts
20
+ import { describe, expect, it } from "@effect/vitest";
21
+ import { Effect } from "effect";
22
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
23
+ import { CurrentUser } from "../../src/identity/Identity.js";
24
+ import { users } from "../../src/identity/users.js";
25
+ import { notes } from "../../src/notes/handlers.js";
26
+ import { NoteNotFound } from "../../src/notes/Notes.js";
27
+ import { NoteStore } from "../../src/notes/store.js";
28
+
29
+ // What the implementation does, with no transport: `Action.client` runs the same decoding,
30
+ // `authorize` and handler a server runs. The caller is provided around each call, as a server
31
+ // provides it per request.
36
32
  const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
37
33
 
38
- it.effect("keeps each user's notes their own", () =>
39
- Effect.gen(function* () {
40
- const client = yield* Action.client(notes);
41
- const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
42
-
43
- expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
44
- }).pipe(Effect.provide(NoteStore.layerMemory)),
45
- );
34
+ describe("notes, in process", () => {
35
+ it.effect("keeps each user's notes their own", () =>
36
+ Effect.gen(function* () {
37
+ const client = yield* Action.client(notes);
38
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
39
+
40
+ expect(yield* client.listNotes().pipe(as("ada"))).toEqual({
41
+ notes: [{ id: note.id, title: "Plan" }],
42
+ });
43
+ expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
44
+
45
+ const missing = new NoteNotFound({ id: note.id });
46
+ const update = { id: note.id, body: "Mine now", revision: note.revision };
47
+
48
+ expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("grace")))).toEqual(
49
+ missing,
50
+ );
51
+ expect(yield* Effect.flip(client.updateNote(update).pipe(as("grace")))).toEqual(missing);
52
+ expect(yield* Effect.flip(client.deleteNote({ id: note.id }).pipe(as("grace")))).toEqual(
53
+ missing,
54
+ );
55
+ expect(yield* client.getNote({ id: note.id }).pipe(as("ada"))).toEqual(note);
56
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
57
+ );
58
+
59
+ it.effect("refuses a write from a caller holding the read scope alone", () =>
60
+ Effect.gen(function* () {
61
+ const client = yield* Action.client(notes);
62
+ const create = client.createNote({ title: "Plan", body: "Draft" });
63
+ const refusal = yield* Effect.flip(create.pipe(as("reader")));
64
+
65
+ expect(refusal).toBeInstanceOf(Action.Forbidden);
66
+ expect(refusal.message).toBe("Requires notes:write.");
67
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
68
+ );
69
+ });
46
70
  ```
47
71
 
48
- See [notes-service/test/notes/notes.test.ts](../examples/notes-service/test/notes/notes.test.ts).
49
-
50
72
  - Replace the edge, not the service under test:
51
73
  [mcp-tools/test/printers/printers.test.ts](../examples/mcp-tools/test/printers/printers.test.ts)
52
74
  runs the real upstream service over a fake `HttpClient` Layer.
53
75
  - `it.effect` runs on a test clock: fork a call that waits, `TestClock.adjust`, then join it.
54
76
  - A stdio server runs the same implementations: test them here. A subprocess test adds only what
55
77
  the process adds, stdout carrying nothing but the protocol.
56
- - A Toolkit and a CLI need no `Testing` export: call `toolkit.handle` or script a
78
+ - A Toolkit and a CLI need no `Testing` export: call `handle` on `yield* toolkit`, or script a
57
79
  `LanguageModel` ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)),
58
- and run commands with `Command.runWith`
59
- ([assistant/test/commands.test.ts](../examples/assistant/test/commands.test.ts)).
80
+ and run commands without a process
81
+ ([ActionCli.md](ActionCli.md#run-actions-as-local-commands)).
60
82
 
61
83
  ## Test routes in memory
62
84
 
63
- Export the routes still requiring what an entrypoint chooses, give the test its own, and provide
64
- the layer to each test:
85
+ Export the routes still requiring what an entrypoint chooses, give the test its own services, and
86
+ provide `Testing.layer(routes)` to each test. What it serves, the requests it reaches, a remote
87
+ `ActionCli` command's included, and what it cannot do are on `Testing.layer`.
65
88
 
66
- ```ts
67
- const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
68
- ```
89
+ The smallest test:
69
90
 
70
- Under it, `ActionHttp.client(Http)` needs no `baseUrl`, and `HttpClient.get("/healthz")` reaches
71
- the host's own routes. Each test builds the routes anew, so each starts from an empty store. See
72
- [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts); the smallest
73
- test is
74
- [quickstart/test/greeting/greeting.test.ts](../examples/quickstart/test/greeting/greeting.test.ts).
75
-
76
- - A service the test reads too goes around `layer` with `Layer.provideMerge`, so handlers and
77
- test share one instance
78
- ([kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts)).
79
- - Two `layer`s are two servers: a second one runs every builder again, and they share only what
80
- is provided around both. For two ports, that is `Action.layer(implementations)` and the
81
- services ([kitchen-sink/test/routes.test.ts](../examples/kitchen-sink/test/routes.test.ts),
82
- [Action-in-process.md](Action-in-process.md#share-one-build-across-servers)).
83
- - The client is `layer`'s own. A fake upstream is another `HttpClient`: provide it to the service
84
- that calls out ([mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts)).
85
- - Every request on `layer`'s client reaches the routes, whatever its host: a client given a
86
- `baseUrl`, a remote `ActionCli` command's included, runs in memory unchanged.
87
- - Requests run in the test's context: a `ConfigProvider` or the test clock around the test
88
- reaches middleware and handlers.
89
- - To send raw web `Request`s too, build one `HttpRouter.toWebHandler` and dispose it yourself;
90
- `Testing.layer(web.handler)` gives the typed clients the same handler
91
- ([kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts)).
91
+ ```ts example=quickstart/test/routes.test.ts
92
+ import { expect, it } from "@effect/vitest";
93
+ import { Effect } from "effect";
94
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
95
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
96
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
97
+ import { Http } from "../src/Api.js";
98
+ import { Greet } from "../src/greeting/Greeting.js";
99
+ import { routes } from "../src/routes.js";
92
100
 
93
- ## Test as a signed-in caller
94
-
95
- The routes keep their real authentication. Serve them behind their provider
96
- ([Authentication.md](Authentication.md#verify-credentials)), and give each client its caller's
97
- credential:
101
+ // The routes answer in memory: no port, and the same code the server runs.
102
+ it.effect("greets over HTTP and as an MCP tool", () =>
103
+ Effect.gen(function* () {
104
+ const http = yield* ActionHttp.client(Http);
105
+ const mcp = yield* ActionMcp.client([Greet]);
98
106
 
99
- ```ts
100
- const as = (token: string) => ({
101
- transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
102
- });
107
+ expect(yield* http.greet({ name: "Ada" })).toBe("Hello, Ada!");
108
+ expect(yield* mcp.greet({ name: "Ada" })).toBe("Hello, Ada!");
109
+ }).pipe(Effect.provide(Testing.layer(routes))),
110
+ );
103
111
  ```
104
112
 
105
- Both clients take it: `ActionHttp.client(Http, as("reader"))` and
106
- `ActionMcp.client([GetStatus, ...Notes], as("ada"))`.
113
+ [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts) provides a
114
+ store a builder yields, calls the host's own routes with `HttpClient.get("/healthz")` and signs
115
+ callers in.
107
116
 
108
- - Never provide the identity to `layer`, around it or to the routes. It is a type error: only a
109
- provider of their own descriptor satisfies them.
110
- - To change what a credential verifies as, replace the service the verifier reads (`servedWith`
111
- in
112
- [kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)),
113
- or provide a test verifier: another `Authentication.layer` of the same descriptor.
117
+ - A service the test reads too:
118
+ [kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts).
119
+ - Several `layer`s sharing one build:
120
+ [kitchen-sink/test/routes.test.ts](../examples/kitchen-sink/test/routes.test.ts)
121
+ ([Action.md](Action.md#share-one-build-across-servers)).
122
+ - A fake upstream: [mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts).
123
+ - Raw web `Request`s beside the typed clients, through one web handler the test disposes of,
124
+ given to `Testing.layer(handler)`:
125
+ [kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts).
114
126
 
115
- A typed client decodes a refusal to its error. To assert on the answer as sent, build the request
116
- (`HttpClientRequest.post`, `bearerToken`, `bodyJsonUnsafe`) and send it with `HttpClient.execute`,
117
- which succeeds whatever the status. Read `response.status` and
118
- `response.headers["www-authenticate"]`, the challenge, as that file does. The status and body of
119
- each case are the table in [guarantees.md](guarantees.md#wire-behavior).
127
+ ## Test as a signed-in caller
120
128
 
121
- ## Test MCP tools
129
+ Serve the routes behind their provider
130
+ ([Authentication.md](Authentication.md#verify-credentials)), and give each client its caller's
131
+ credential in `transformClient`, as `as` in
132
+ [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts) does for
133
+ `ActionHttp.client` and `ActionMcp.client` alike.
122
134
 
123
- `ActionMcp.client(actions, options?)` gives one method per action, calling its tool and decoding
124
- the result as `ActionHttp.client` decodes a route's, an image included. Pass the contract values
125
- the endpoint serves, such as `implementation.actions`. Its failures:
126
- [ActionMcp-failures.md](ActionMcp-failures.md).
127
-
128
- A method fails with the action's declared errors and the built-in ones as decoded values, and with
129
- `McpCallError` for any other answer: its `reason` says which, `StatusError`, `ProtocolError`,
130
- `NoReply`, `UndeclaredError` or `NoStructuredContent`, with what the endpoint sent, so a test
131
- asserts on `reason` and `Effect.catchReason("McpCallError", "StatusError", ...)` recovers one;
132
- structured content that does not decode as the success is a `SchemaError`, and a request that
133
- fails Effect's `HttpClientError`. Only a tool's result carries the action's own errors: an HTTP
134
- answer other than 200 decodes as a refusal, or, given the endpoint's `authentication`, as an error
135
- its verifier declares for a protected tool, as `ActionHttp.client` types it; otherwise it is an
136
- `McpCallError`
137
- ([kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)).
138
-
139
- A method decodes a refusal alike whether the endpoint answered a 401 or a 403 or returned a tool
140
- result in a 200 ([guarantees.md](guarantees.md#wire-behavior)): to assert which, send the call
141
- yourself and read its status.
142
-
143
- For that, and for what the client's types will not send or its decoding hides (`tools/list`, an
144
- `Origin` header, a refusal's challenge, a malformed call), build the request with
145
- `ActionMcp.request`. It returns a native `HttpClientRequest`:
146
- `yield* HttpClient.execute(ActionMcp.request("tools/call", { name: "whoAmI", arguments: {} }))`
147
- succeeds with the response whatever its status, to read `response.status`, `response.headers`
148
- and `yield* response.json`
149
- ([kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)
150
- asserts a 401 so, and
151
- [mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts) reads `tools/list`).
152
- For a web handler or `fetch`, pass an absolute `url`: `HttpClientRequest.toWebResult(request)` is
153
- then a `Result` holding the web `Request`, taken with `Result.getOrThrow`.
135
+ To change what a credential verifies as, replace the service the verifier reads (`servedWith` in
136
+ [kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)),
137
+ or provide a test verifier: another `Authentication.layer` of the same descriptor.
154
138
 
155
- ## Test rpcs
139
+ A typed client decodes a refusal to its error. To assert on the answer as sent, its status and
140
+ its `www-authenticate` challenge, send the request with `HttpClient.execute`, as that file does;
141
+ a tool call's request is built with `ActionMcp.request` ([Test MCP tools](#test-mcp-tools)).
142
+ The status and body of each case are on `ActionHttp.layer` and on each built-in error.
156
143
 
157
- `Testing.layer` answers HTTP requests and cannot upgrade a WebSocket. Export the rpc layer without
158
- its protocol ([ActionRpc.md](ActionRpc.md#serve-the-rpcs)), and give it Effect's HTTP protocol in
159
- the test, on both sides:
144
+ ## Test MCP tools
160
145
 
161
- ```ts
162
- const server = rpc.pipe(
163
- Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
164
- Layer.provide(RpcSerialization.layerJson),
165
- );
146
+ `ActionMcp.client(actions, options?)` gives one method per action, calling its tool. What it
147
+ decodes and how it fails are on `ActionMcp.client` and `McpCallError`
148
+ ([ActionMcp-failures.md](ActionMcp-failures.md)).
166
149
 
167
- const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
168
- Layer.provide(RpcSerialization.layerJson),
169
- Layer.provide(Testing.layer(server)),
170
- Layer.provide(MessageStore.layerMemory),
171
- );
150
+ For what the client's types will not send or its decoding hides, build the request with
151
+ `ActionMcp.request`, which says how to send it:
152
+ [kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)
153
+ asserts a 401, and
154
+ [mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts) reads `tools/list`.
172
155
 
173
- const as = (token: string) => RpcClient.withHeaders({ authorization: `Bearer ${token}` });
174
- ```
156
+ ## Test rpcs
175
157
 
176
- Authentication is per message, so each call carries its credential and one client serves every
177
- caller: `client.postMessage({ text }).pipe(as("ada"))`. See
178
- [live-rpc/test/routes.test.ts](../examples/live-rpc/test/routes.test.ts);
158
+ Export the rpc layer without its protocol ([ActionRpc.md](ActionRpc.md#serve-the-rpcs)) and
159
+ serve it under `Testing.layer`, as
160
+ [live-rpc/test/routes.test.ts](../examples/live-rpc/test/routes.test.ts) does; the protocol each
161
+ side takes is on `Testing.layer`.
179
162
  [live-rpc/test/browser.test.ts](../examples/live-rpc/test/browser.test.ts) tests a page's client
180
- the same way. To call JSON routes in the same test, merge both route layers into the one
181
- `Testing.layer` and give it to the protocol with `Layer.provideMerge`, which keeps its `HttpClient`
182
- for the test
183
- ([kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts)).
184
- Effect's `RpcTest.makeClient` is no substitute: it asks for handlers of the
185
- binding's group, which `ActionRpc.layer` does not provide (`Handler<"…">` stays required).
163
+ the same way, and
164
+ [kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts)
165
+ serves JSON routes and rpcs in one `Testing.layer`.
186
166
 
187
167
  ## Assert on errors
188
168
 
189
169
  Take the failure with `Effect.flip(call)`, which alone asserts only that the call failed, then
190
- assert its class and its `message` separately:
191
-
192
- ```ts
193
- expect(refusal).toBeInstanceOf(Action.Forbidden);
194
- expect(refusal.message).toBe("Requires notes:write.");
195
- ```
170
+ assert its class and its `message` separately, as the last test of
171
+ [notes-service/test/notes/in-process.test.ts](../examples/notes-service/test/notes/in-process.test.ts) does.
196
172
 
197
173
  `toEqual` never compares a tagged error's `message`, which is not an enumerable property:
198
174
  `expect(error).toEqual(new Action.Forbidden({ message: "anything" }))` passes for every message,
199
175
  and so does `toStrictEqual`. Other fields are compared, so `toEqual(new NoteNotFound({ id }))`
200
176
  asserts the `id`, and `toEqual(new Action.Forbidden({ scopes: ["issues:write"] }))` still needs the
201
- message asserted beside it. The message is all a model reads of a tool's failure.
202
-
203
- `toBeInstanceOf` narrows nothing: the flipped failure stays the union of the action's declared
204
- and built-in errors, so reading a field only one of them has, such as `retryAfterSeconds`, does
205
- not compile. Compare with `toEqual(new RateLimited({ retryAfterSeconds: 10 }))`, or narrow with
206
- `instanceof` first.
207
-
208
- A Toolkit raises neither a refusal nor a declared error: each is a result the model reads, so
209
- assert `isFailure` and `result.message`
210
- ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)).
177
+ message asserted beside it.
@@ -3,35 +3,11 @@
3
3
  What you see when something is wrong, and the fix. Search for a fragment of the message.
4
4
  The tasks are in [setup.md](setup.md).
5
5
 
6
- - `TS1149: File name '…/assistant.ts' differs from already included file name '…/Assistant.ts' only in casing`:
7
- a lowercase file is named for its domain beside the domain's contract. Name lowercase files for
8
- their role, such as `handlers.ts` or `turns.ts`: on a disk that ignores case the two are one file.
9
- - `Cannot find module '@gjermundgaraba/effect-actions'`: there is no root export. Import a
10
- subpath from [Entry points](setup.md#entry-points), never the root or a `dist/` path.
11
- - A browser build fails with `Could not resolve "node:…"`, or warns
12
- `Module "node:…" has been externalized for browser compatibility`: the page imports a file that
13
- also holds server code, such as a contract beside its `Action.implement`, or an authentication
14
- descriptor beside its `Authentication.layer`. Move contracts, identities, descriptors and
15
- bindings to files that import no server code.
16
- - A page that loads Effect from an import map still bundles a second copy of Effect or of some
17
- of its modules, or fails to resolve `effect/Schema` in the browser when its bundler
18
- externalizes subpaths, as esbuild's `--external:effect` does: a module of the page imports an
19
- Effect specifier the map does not serve. Serve the specifiers
20
- [Bundle for a browser](setup.md#bundle-for-a-browser) lists, and import Effect through them in
21
- the page's own modules too.
22
- - A page loading Effect from an import map fails with
23
- `Failed to resolve module specifier "effect/Cause"` in Chromium: the map points at Effect's
24
- published files and lacks a specifier Effect imports itself. Map `effect/Cause`,
25
- `effect/Effect`, `effect/Exit` and `effect/Function` to the same files, or serve Effect from a
26
- build that resolves its own imports.
27
- - A page fails at load with `does not provide an export named …`: it serves only the names it
28
- uses of an Effect specifier, from a vendored module or a trimmed bundle, and the package
29
- imports another. Serve each specifier whole.
30
- - Type errors inside `effect/*` modules after install, an unmet peer warning for `effect`,
31
- such as `found 4.1.0`, or a start failing with `Cannot find module '…/effect/dist/…'` under an
32
- `@effect/*` package: more than one Effect release is installed, or one outside the peer
33
- range. Install the `effect` range [Install](setup.md#install) names, with every `@effect/*`
34
- package at the version `effect` resolved to, such as `@effect/platform-node@4.0.4` beside
35
- `effect@4.0.4`.
36
- - `Cannot find module 'effect/unstable/http'` (or `…/httpapi`, `…/cli`, `…/ai`): these modules
37
- are now `effect/http`, `effect/http-api`, `effect/cli` and `effect/ai`.
6
+ - `TS1149: File name '…/assistant.ts' differs from already included file name '…/Assistant.ts' only in casing`: a lowercase file is named for its domain beside the domain's contract. Name lowercase files for their role, such as `handlers.ts` or `turns.ts`: on a disk that ignores case the two are one file.
7
+ - `Cannot find module '@gjermundgaraba/effect-actions'`: there is no root export. Import a subpath from [Entry points](setup.md#entry-points), never the root or a `dist/` path.
8
+ - A browser build fails with `Could not resolve "node:…"`, or warns `Module "node:…" has been externalized for browser compatibility`: the page imports a file that also holds server code, such as a contract beside its `Action.implement`, or an authentication descriptor beside its `Authentication.layer`. Move contracts, identities, descriptors and bindings to files that import no server code.
9
+ - A page that loads Effect from an import map still bundles a second copy of Effect or of some of its modules, or fails to resolve `effect/Schema` in the browser when its bundler externalizes subpaths, as esbuild's `--external:effect` does: a module of the page imports an Effect specifier the map does not serve. Serve the specifiers [Bundle for a browser](setup.md#bundle-for-a-browser) lists, and import Effect through them in the page's own modules too.
10
+ - A page loading Effect from an import map fails with `Failed to resolve module specifier "effect/Cause"` in Chromium: the map points at Effect's published files and lacks a specifier Effect imports itself. Map `effect/Cause`, `effect/Effect`, `effect/Exit` and `effect/Function` to the same files, or serve Effect from a build that resolves its own imports.
11
+ - A page fails at load with `does not provide an export named …`: it serves only the names it uses of an Effect specifier, from a vendored module or a trimmed bundle, and the package imports another. Serve each specifier whole.
12
+ - Type errors inside `effect/*` modules after install, an unmet peer warning for `effect`, such as `found 4.1.0`, or a start failing with `Cannot find module '…/effect/dist/…'` under an `@effect/*` package: more than one Effect release is installed, or one outside the peer range. Install the `effect` range [Install](setup.md#install) names, with every `@effect/*` package at the version `effect` resolved to, such as `@effect/platform-node@4.0.4` beside `effect@4.0.4`.
13
+ - `Cannot find module 'effect/unstable/http'` (or `…/httpapi`, `…/cli`, `…/ai`): these modules are now `effect/http`, `effect/http-api`, `effect/cli` and `effect/ai`.
package/docs/setup.md CHANGED
@@ -3,9 +3,7 @@
3
3
  Install the package, import its modules, write the smallest program, lay out an application and
4
4
  bundle a browser client.
5
5
 
6
- Read this page alone, in one tool call; if its output says it was truncated, read it again alone.
7
- When something fails, search [setup-failures.md](setup-failures.md). Reading order:
8
- [README.md](README.md).
6
+ A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search [setup-failures.md](setup-failures.md).
9
7
 
10
8
  ## Install
11
9
 
@@ -18,6 +16,7 @@ pnpm add @gjermundgaraba/effect-actions effect@~4.0.4
18
16
  | Node HTTP server, stdio host or CLI | `@effect/platform-node@~4.0.4` |
19
17
  | Tests | `vitest@^5` and `@effect/vitest@~4.0.4`, as dev dependencies |
20
18
  | Type checking | `typescript@^7.0.2`, as a dev dependency |
19
+ | Node's types, for `node:` imports | `@types/node`, as a dev dependency |
21
20
  | Running a TypeScript entrypoint | `tsx`, as a dev dependency: `node --import tsx <entrypoint>.ts` |
22
21
 
23
22
  The `effect` peer admits the 4.0.x patches from the oldest whose APIs the package uses, and no
@@ -26,9 +25,8 @@ minor release: a minor release may change the unstable Effect modules the surfac
26
25
  Keep the tilde in `package.json`: `^4.0.4`, which npm writes for `effect@4.0.4`, admits a minor
27
26
  release outside the peer range. `npm install effect@~4.0.4` keeps it.
28
27
 
29
- Node `^22.12.0 || ^24.0.0 || >=26.0.0`: the package's `engines`, where its `devEngines` is
30
- what developing the library itself takes. TypeScript 7 or newer. ESM only. No example ships a
31
- `tsconfig.json`; this one checks them. The package's modules resolve only through its
28
+ Node `>=26`: the package's `engines`, the Node the library is developed and tested on. TypeScript 7 or newer. ESM only. The repository checks the
29
+ examples with a stricter `tsconfig.json`; this is the minimum a project needs. The package's modules resolve only through its
32
30
  `exports`, which `"module": "nodenext"` reads, as a bundler project's
33
31
  `"moduleResolution": "bundler"` does. `"strict": true` is required, by Effect:
34
32
 
@@ -40,7 +38,8 @@ what developing the library itself takes. TypeScript 7 or newer. ESM only. No ex
40
38
  "strict": true,
41
39
  "exactOptionalPropertyTypes": true,
42
40
  "noEmit": true,
43
- "skipLibCheck": true
41
+ "skipLibCheck": true,
42
+ "types": ["node"]
44
43
  },
45
44
  "include": ["src", "test"]
46
45
  }
@@ -48,18 +47,10 @@ what developing the library itself takes. TypeScript 7 or newer. ESM only. No ex
48
47
 
49
48
  ## Entry points
50
49
 
51
- There is no package root. Import one module per subpath, as a namespace:
52
-
53
- ```ts
54
- import * as Action from "@gjermundgaraba/effect-actions/Action";
55
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
56
- import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
57
- import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
58
- import * as ActionToolkit from "@gjermundgaraba/effect-actions/ActionToolkit";
59
- import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
60
- import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
61
- import * as Testing from "@gjermundgaraba/effect-actions/Testing";
62
- ```
50
+ There is no package root. Import one module per subpath, as a namespace,
51
+ `import * as Action from "@gjermundgaraba/effect-actions/Action"`.
52
+ The modules: `Action`, `ActionHttp`, `ActionMcp`, `ActionRpc`, `ActionToolkit`, `ActionCli`,
53
+ `Authentication`, `Testing`.
63
54
 
64
55
  Effect's own modules are subpaths of `effect` too: `effect/http` (`HttpRouter`, `HttpClient`,
65
56
  `FetchHttpClient`), `effect/http-api` (`OpenApi`, `HttpApiMiddleware`), `effect/rpc`
@@ -71,8 +62,12 @@ Effect's own modules are subpaths of `effect` too: `effect/http` (`HttpRouter`,
71
62
  One action, served as a JSON route and as an MCP tool: four declarations, each in a file of
72
63
  [quickstart/src/](../examples/quickstart/src/).
73
64
 
74
- ```ts
75
- // greeting/Greeting.ts: the contract. It imports no server code.
65
+ ```ts example=quickstart/src/greeting/Greeting.ts
66
+ import { Schema } from "effect";
67
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
68
+
69
+ // A contract: what the action takes and answers, whether it changes anything, and who may
70
+ // call it. It imports no server code, so any client can import it, a browser page included.
76
71
  export const Greet = Action.make("greet", {
77
72
  description: "Greet someone by name.",
78
73
  input: { name: Schema.String },
@@ -80,14 +75,33 @@ export const Greet = Action.make("greet", {
80
75
  readOnly: true,
81
76
  caller: Action.Anyone,
82
77
  });
78
+ ```
79
+
80
+ ```ts example=quickstart/src/greeting/handlers.ts
81
+ import { Effect } from "effect";
82
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
83
+ import { Greet } from "./Greeting.js";
83
84
 
84
- // greeting/handlers.ts: the implementation. Every surface takes this one value.
85
+ // The implementation: the contract bound to its handler. Every surface takes this one value.
85
86
  export const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
87
+ ```
86
88
 
87
- // Api.ts: the HTTP binding, which the server serves and the client calls.
89
+ ```ts example=quickstart/src/Api.ts
90
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
91
+ import { Greet } from "./greeting/Greeting.js";
92
+
93
+ // The HTTP binding: `POST /api/greet`. The server serves it and the client calls it.
88
94
  export const Http = ActionHttp.make([Greet]);
95
+ ```
89
96
 
90
- // routes.ts: the surfaces as one Layer.
97
+ ```ts example=quickstart/src/routes.ts
98
+ import { Layer } from "effect";
99
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
100
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
101
+ import { Http } from "./Api.js";
102
+ import { greeting } from "./greeting/handlers.js";
103
+
104
+ // One implementation, two surfaces: a JSON route and an MCP tool at `/mcp`.
91
105
  export const routes = Layer.mergeAll(
92
106
  ActionHttp.layer(Http, greeting),
93
107
  ActionMcp.layerHttp(greeting, { name: "greetings", version: "1.0.0" }),
@@ -95,22 +109,15 @@ export const routes = Layer.mergeAll(
95
109
  ```
96
110
 
97
111
  Serve `routes` with `HttpRouter.serve` and a platform server layer
98
- ([quickstart/src/server.ts](../examples/quickstart/src/server.ts)) and you have `POST /api/greet`
99
- and an MCP tool `greet` at `/mcp`. Call the route with the binding's typed client
100
- ([quickstart/src/client.ts](../examples/quickstart/src/client.ts)), and test both in memory
101
- ([quickstart/test/greeting/greeting.test.ts](../examples/quickstart/test/greeting/greeting.test.ts)).
102
-
103
- A request body has no size limit unless the host sets one
104
- ([ActionHttp.md](ActionHttp.md#serve-the-routes)).
112
+ ([quickstart/src/server.ts](../examples/quickstart/src/server.ts)), call the route with the
113
+ binding's typed client ([quickstart/src/client.ts](../examples/quickstart/src/client.ts)), and
114
+ test both in memory ([quickstart/test/routes.test.ts](../examples/quickstart/test/routes.test.ts)).
105
115
  [notes-service](../examples/notes-service/) is this program grown into a service, in the same
106
116
  layout.
107
117
 
108
118
  ## Lay out an application
109
119
 
110
- Folders are domains; the PascalCase file in each is its contract file, which imports no server
111
- code; `routes.ts` holds the surfaces; each entrypoint chooses the services. An entrypoint of a
112
- remote surface never provides an identity: authentication supplies it per request
113
- ([Action.md](Action.md#say-who-may-call-an-action)). The tree:
120
+ Copy the examples' layout:
114
121
  [examples/README.md](../examples/README.md#how-they-are-laid-out).
115
122
 
116
123
  ## Bundle for a browser
@@ -126,6 +133,3 @@ promises, [live-rpc/src/browser.ts](../examples/live-rpc/src/browser.ts) over a
126
133
  - Those four modules import Effect through `effect`, `effect/http` and `effect/http-api` alone,
127
134
  and `ActionRpc` through `effect/rpc` too. A page loading Effect from an import map serves
128
135
  those, and `effect/socket` for a WebSocket client.
129
- - A page on another origin needs CORS on the host, answered before authentication:
130
- `HttpRouter.cors`, as in
131
- [kitchen-sink/src/request-policy.ts](../examples/kitchen-sink/src/request-policy.ts).
@@ -5,14 +5,9 @@ run, with its own tests and a README that says what it shows and where. They are
5
5
  linted and tested with the library, and they ship in the package, at
6
6
  `node_modules/@gjermundgaraba/effect-actions/examples`.
7
7
 
8
- | Example | What it is | Start from it when |
9
- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
10
- | [quickstart](quickstart/) | One action, served as a JSON route and an MCP tool, called with its typed client. | You want the smallest thing that runs. |
11
- | [notes-service](notes-service/) | A service with bearer authentication: a JSON API and MCP tools from one implementation, a CLI, a browser client. | You are building a service others call. Most applications are this. |
12
- | [mcp-tools](mcp-tools/) | An MCP server whose tools wrap an upstream HTTP API, over stdio and over HTTP, with an image result. | You are putting an existing API in front of a model. |
13
- | [assistant](assistant/) | No server: the same implementations as a model's tools, with approval of writes, and as local commands. | A model calls your code in process, or you are building a local CLI. |
14
- | [live-rpc](live-rpc/) | Effect RPC over a WebSocket with a browser-shaped client, each message carrying its own token. | A client keeps a connection open, or the application speaks Effect RPC. |
15
- | [kitchen-sink](kitchen-sink/) | The reference: a multi-tenant issue tracker using every module and nearly every option, with an index. | You need a working use of one specific feature. |
8
+ [quickstart](quickstart/), [notes-service](notes-service/), [mcp-tools](mcp-tools/),
9
+ [assistant](assistant/), [live-rpc](live-rpc/) and [kitchen-sink](kitchen-sink/): which to start
10
+ from, by what you are building, is the table in [docs/README.md](../docs/README.md#examples).
16
11
 
17
12
  ## Run one
18
13
 
@@ -25,8 +20,9 @@ vp test examples/notes-service
25
20
  ```
26
21
 
27
22
  In a project that installs the package, copy an example's directory out of `node_modules` and
28
- run it the same way: the examples import the package by its name. They need `tsx` and
29
- `@effect/platform-node` beside `effect`, and `vitest` with `@effect/vitest` for their tests.
23
+ run it the same way: the examples import the package by its name. They need `tsx`,
24
+ `@effect/platform-node` and `@types/node` beside `effect`, and `vitest` with `@effect/vitest` for
25
+ their tests.
30
26
 
31
27
  ## How they are laid out
32
28