@gjermundgaraba/effect-actions 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -47
- package/dist/Action.d.mts +96 -47
- package/dist/Action.mjs +11 -14
- package/dist/ActionCli.d.mts +81 -42
- package/dist/ActionCli.mjs +52 -33
- package/dist/ActionHttp.d.mts +84 -43
- package/dist/ActionHttp.mjs +2 -2
- package/dist/ActionMcp.d.mts +54 -27
- package/dist/ActionMcp.mjs +5 -5
- package/dist/ActionRpc.d.mts +48 -35
- package/dist/ActionRpc.mjs +1 -1
- package/dist/ActionToolkit.d.mts +32 -13
- package/dist/ActionToolkit.mjs +10 -7
- package/dist/Authentication.d.mts +92 -40
- package/dist/Authentication.mjs +37 -35
- package/dist/Testing.d.mts +22 -24
- package/dist/authentication/provider.d.mts +1 -4
- package/dist/authentication/provider.mjs +1 -1
- package/dist/authentication/refusal.mjs +2 -1
- package/dist/cli/commands.d.mts +34 -11
- package/dist/cli/commands.mjs +47 -22
- package/dist/contract/caller.mjs +1 -1
- package/dist/contract/declared.mjs +27 -14
- package/dist/contract/errors.d.mts +42 -17
- package/dist/contract/errors.mjs +42 -17
- package/dist/contract/hints.mjs +3 -2
- package/dist/contract/implementation.d.mts +5 -8
- package/dist/contract/implementation.mjs +29 -17
- package/dist/contract/media.d.mts +6 -2
- package/dist/contract/media.mjs +6 -2
- package/dist/contract/names.mjs +14 -12
- package/dist/contract/status.mjs +3 -10
- package/dist/http/client.d.mts +15 -4
- package/dist/http/client.mjs +1 -1
- package/dist/mcp/client.d.mts +21 -8
- package/dist/mcp/client.mjs +6 -8
- package/docs/Action-failures.md +6 -15
- package/docs/Action.md +159 -191
- package/docs/ActionCli-failures.md +12 -92
- package/docs/ActionCli.md +102 -156
- package/docs/ActionHttp-failures.md +14 -76
- package/docs/ActionHttp.md +150 -176
- package/docs/ActionMcp-failures.md +2 -22
- package/docs/ActionMcp.md +79 -170
- package/docs/ActionRpc-failures.md +3 -12
- package/docs/ActionRpc.md +183 -120
- package/docs/ActionToolkit-failures.md +3 -10
- package/docs/ActionToolkit.md +112 -109
- package/docs/Authentication-advanced.md +167 -108
- package/docs/Authentication-failures.md +11 -91
- package/docs/Authentication.md +114 -135
- package/docs/README.md +45 -30
- package/docs/Testing-failures.md +2 -6
- package/docs/Testing.md +126 -159
- package/docs/setup-failures.md +8 -32
- package/docs/setup.md +42 -37
- package/examples/README.md +6 -10
- package/examples/assistant/README.md +20 -38
- package/examples/assistant/src/agent.ts +2 -3
- package/examples/assistant/src/cli.ts +4 -9
- package/examples/assistant/src/commands.ts +6 -4
- package/examples/assistant/src/identity/authorize.ts +3 -3
- package/examples/assistant/src/tasks/Tasks.ts +1 -2
- package/examples/assistant/src/tasks/handlers.ts +3 -3
- package/examples/assistant/src/toolkit.ts +6 -8
- package/examples/assistant/test/commands.test.ts +16 -11
- package/examples/assistant/test/tasks/tasks.test.ts +0 -2
- package/examples/assistant/test/toolkit.test.ts +0 -1
- package/examples/kitchen-sink/README.md +30 -52
- package/examples/kitchen-sink/src/Actions.ts +2 -3
- package/examples/kitchen-sink/src/Api.ts +3 -4
- package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
- package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
- package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
- package/examples/kitchen-sink/src/agent.ts +1 -1
- package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
- package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
- package/examples/kitchen-sink/src/attachments/download.ts +5 -8
- package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
- package/examples/kitchen-sink/src/browser.ts +3 -4
- package/examples/kitchen-sink/src/cli.ts +6 -7
- package/examples/kitchen-sink/src/config.ts +2 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
- package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
- package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
- package/examples/kitchen-sink/src/identity/quota.ts +1 -1
- package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
- package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
- package/examples/kitchen-sink/src/issues/guide.ts +2 -3
- package/examples/kitchen-sink/src/ops.ts +3 -6
- package/examples/kitchen-sink/src/request-policy.ts +3 -3
- package/examples/kitchen-sink/src/routes.ts +4 -6
- package/examples/kitchen-sink/src/seed.ts +0 -1
- package/examples/kitchen-sink/src/server.ts +11 -12
- package/examples/kitchen-sink/src/stdio.ts +3 -2
- package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
- package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
- package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
- package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
- package/examples/kitchen-sink/test/request-policy.test.ts +1 -1
- package/examples/kitchen-sink/test/routes.test.ts +3 -3
- package/examples/live-rpc/README.md +8 -49
- package/examples/live-rpc/src/browser.ts +8 -41
- package/examples/live-rpc/src/chat/Chat.ts +9 -3
- package/examples/live-rpc/src/chat/room.ts +1 -1
- package/examples/live-rpc/src/client.ts +2 -3
- package/examples/live-rpc/src/page.ts +34 -0
- package/examples/live-rpc/src/routes.ts +6 -7
- package/examples/live-rpc/src/server.ts +6 -4
- package/examples/live-rpc/test/browser.test.ts +6 -5
- package/examples/live-rpc/test/routes.test.ts +16 -15
- package/examples/live-rpc/test/server.test.ts +1 -1
- package/examples/mcp-tools/README.md +22 -45
- package/examples/mcp-tools/src/printers/Printers.ts +3 -7
- package/examples/mcp-tools/src/routes.ts +0 -7
- package/examples/mcp-tools/src/server.ts +1 -1
- package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
- package/examples/mcp-tools/test/routes.test.ts +2 -3
- package/examples/notes-service/README.md +9 -43
- package/examples/notes-service/src/cli.ts +0 -3
- package/examples/notes-service/src/commands.ts +7 -17
- package/examples/notes-service/src/identity/authorize.ts +2 -5
- package/examples/notes-service/src/identity/verifier.ts +3 -4
- package/examples/notes-service/src/notes/Notes.ts +2 -6
- package/examples/notes-service/src/notes/store.ts +5 -1
- package/examples/notes-service/src/routes.ts +6 -8
- package/examples/notes-service/src/server.ts +4 -3
- package/examples/notes-service/src/stdio.ts +4 -3
- package/examples/notes-service/test/commands.test.ts +1 -1
- package/examples/notes-service/test/notes/as.ts +6 -0
- package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
- package/examples/notes-service/test/notes/in-process.test.ts +50 -0
- package/examples/notes-service/test/notes/notes.test.ts +3 -42
- package/examples/notes-service/test/routes.test.ts +2 -2
- package/examples/quickstart/README.md +1 -1
- package/examples/quickstart/src/client.ts +1 -0
- package/examples/quickstart/src/server.ts +2 -4
- package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
- package/package.json +1 -1
- package/docs/Action-in-process.md +0 -93
- package/docs/CONTEXT.md +0 -46
- 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
|
|
8
|
-
`Scope
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 `
|
|
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
|
|
59
|
-
([
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
|
|
68
|
-
```
|
|
89
|
+
The smallest test:
|
|
69
90
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
-
|
|
109
|
-
|
|
110
|
-
-
|
|
111
|
-
|
|
112
|
-
[
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
```
|
|
156
|
+
## Test rpcs
|
|
175
157
|
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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.
|
package/docs/setup-failures.md
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
- `
|
|
10
|
-
|
|
11
|
-
- A
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
@@ -27,8 +26,8 @@ Keep the tilde in `package.json`: `^4.0.4`, which npm writes for `effect@4.0.4`,
|
|
|
27
26
|
release outside the peer range. `npm install effect@~4.0.4` keeps it.
|
|
28
27
|
|
|
29
28
|
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.
|
|
31
|
-
`tsconfig.json`; this
|
|
29
|
+
what developing the library itself takes. TypeScript 7 or newer. ESM only. The repository checks the
|
|
30
|
+
examples with a stricter `tsconfig.json`; this is the minimum a project needs. The package's modules resolve only through its
|
|
32
31
|
`exports`, which `"module": "nodenext"` reads, as a bundler project's
|
|
33
32
|
`"moduleResolution": "bundler"` does. `"strict": true` is required, by Effect:
|
|
34
33
|
|
|
@@ -40,7 +39,8 @@ what developing the library itself takes. TypeScript 7 or newer. ESM only. No ex
|
|
|
40
39
|
"strict": true,
|
|
41
40
|
"exactOptionalPropertyTypes": true,
|
|
42
41
|
"noEmit": true,
|
|
43
|
-
"skipLibCheck": true
|
|
42
|
+
"skipLibCheck": true,
|
|
43
|
+
"types": ["node"]
|
|
44
44
|
},
|
|
45
45
|
"include": ["src", "test"]
|
|
46
46
|
}
|
|
@@ -48,18 +48,10 @@ what developing the library itself takes. TypeScript 7 or newer. ESM only. No ex
|
|
|
48
48
|
|
|
49
49
|
## Entry points
|
|
50
50
|
|
|
51
|
-
There is no package root. Import one module per subpath, as a namespace
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
```
|
|
51
|
+
There is no package root. Import one module per subpath, as a namespace,
|
|
52
|
+
`import * as Action from "@gjermundgaraba/effect-actions/Action"`.
|
|
53
|
+
The modules: `Action`, `ActionHttp`, `ActionMcp`, `ActionRpc`, `ActionToolkit`, `ActionCli`,
|
|
54
|
+
`Authentication`, `Testing`.
|
|
63
55
|
|
|
64
56
|
Effect's own modules are subpaths of `effect` too: `effect/http` (`HttpRouter`, `HttpClient`,
|
|
65
57
|
`FetchHttpClient`), `effect/http-api` (`OpenApi`, `HttpApiMiddleware`), `effect/rpc`
|
|
@@ -71,8 +63,12 @@ Effect's own modules are subpaths of `effect` too: `effect/http` (`HttpRouter`,
|
|
|
71
63
|
One action, served as a JSON route and as an MCP tool: four declarations, each in a file of
|
|
72
64
|
[quickstart/src/](../examples/quickstart/src/).
|
|
73
65
|
|
|
74
|
-
```ts
|
|
75
|
-
|
|
66
|
+
```ts example=quickstart/src/greeting/Greeting.ts
|
|
67
|
+
import { Schema } from "effect";
|
|
68
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
69
|
+
|
|
70
|
+
// A contract: what the action takes and answers, whether it changes anything, and who may
|
|
71
|
+
// call it. It imports no server code, so any client can import it, a browser page included.
|
|
76
72
|
export const Greet = Action.make("greet", {
|
|
77
73
|
description: "Greet someone by name.",
|
|
78
74
|
input: { name: Schema.String },
|
|
@@ -80,14 +76,33 @@ export const Greet = Action.make("greet", {
|
|
|
80
76
|
readOnly: true,
|
|
81
77
|
caller: Action.Anyone,
|
|
82
78
|
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```ts example=quickstart/src/greeting/handlers.ts
|
|
82
|
+
import { Effect } from "effect";
|
|
83
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
84
|
+
import { Greet } from "./Greeting.js";
|
|
83
85
|
|
|
84
|
-
//
|
|
86
|
+
// The implementation: the contract bound to its handler. Every surface takes this one value.
|
|
85
87
|
export const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
|
|
88
|
+
```
|
|
86
89
|
|
|
87
|
-
|
|
90
|
+
```ts example=quickstart/src/Api.ts
|
|
91
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
92
|
+
import { Greet } from "./greeting/Greeting.js";
|
|
93
|
+
|
|
94
|
+
// The HTTP binding: `POST /api/greet`. The server serves it and the client calls it.
|
|
88
95
|
export const Http = ActionHttp.make([Greet]);
|
|
96
|
+
```
|
|
89
97
|
|
|
90
|
-
|
|
98
|
+
```ts example=quickstart/src/routes.ts
|
|
99
|
+
import { Layer } from "effect";
|
|
100
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
101
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
102
|
+
import { Http } from "./Api.js";
|
|
103
|
+
import { greeting } from "./greeting/handlers.js";
|
|
104
|
+
|
|
105
|
+
// One implementation, two surfaces: a JSON route and an MCP tool at `/mcp`.
|
|
91
106
|
export const routes = Layer.mergeAll(
|
|
92
107
|
ActionHttp.layer(Http, greeting),
|
|
93
108
|
ActionMcp.layerHttp(greeting, { name: "greetings", version: "1.0.0" }),
|
|
@@ -95,22 +110,15 @@ export const routes = Layer.mergeAll(
|
|
|
95
110
|
```
|
|
96
111
|
|
|
97
112
|
Serve `routes` with `HttpRouter.serve` and a platform server layer
|
|
98
|
-
([quickstart/src/server.ts](../examples/quickstart/src/server.ts))
|
|
99
|
-
|
|
100
|
-
([quickstart/
|
|
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)).
|
|
113
|
+
([quickstart/src/server.ts](../examples/quickstart/src/server.ts)), call the route with the
|
|
114
|
+
binding's typed client ([quickstart/src/client.ts](../examples/quickstart/src/client.ts)), and
|
|
115
|
+
test both in memory ([quickstart/test/routes.test.ts](../examples/quickstart/test/routes.test.ts)).
|
|
105
116
|
[notes-service](../examples/notes-service/) is this program grown into a service, in the same
|
|
106
117
|
layout.
|
|
107
118
|
|
|
108
119
|
## Lay out an application
|
|
109
120
|
|
|
110
|
-
|
|
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:
|
|
121
|
+
Copy the examples' layout:
|
|
114
122
|
[examples/README.md](../examples/README.md#how-they-are-laid-out).
|
|
115
123
|
|
|
116
124
|
## Bundle for a browser
|
|
@@ -126,6 +134,3 @@ promises, [live-rpc/src/browser.ts](../examples/live-rpc/src/browser.ts) over a
|
|
|
126
134
|
- Those four modules import Effect through `effect`, `effect/http` and `effect/http-api` alone,
|
|
127
135
|
and `ActionRpc` through `effect/rpc` too. A page loading Effect from an import map serves
|
|
128
136
|
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).
|
package/examples/README.md
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
29
|
-
`@effect/platform-node` beside `effect`, and `vitest` with `@effect/vitest` for
|
|
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
|
|