@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/README.md
CHANGED
|
@@ -4,21 +4,16 @@ Define an Effect action once. Serve it over HTTP, MCP and Effect RPC, hand it to
|
|
|
4
4
|
native Toolkit, and run it from a CLI. Same schemas, same handler, Effect's own servers and clients
|
|
5
5
|
underneath.
|
|
6
6
|
|
|
7
|
-
**If you are a coding agent**, start at [docs/README.md](docs/README.md), not here
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
**If you are a coding agent**, start at [docs/README.md](docs/README.md), not here. It routes
|
|
8
|
+
every task, protecting an action and limiting request bodies included, to its page and its
|
|
9
|
+
example.
|
|
10
10
|
|
|
11
11
|
## Looks like this
|
|
12
12
|
|
|
13
13
|
A contract says what an action takes and answers, and who may call it:
|
|
14
14
|
|
|
15
|
-
```ts
|
|
16
|
-
|
|
17
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
18
|
-
|
|
19
|
-
// A contract: what the action takes and answers, whether it changes anything, and who may
|
|
20
|
-
// call it. It imports no server code, so any client can import it, a browser page included.
|
|
21
|
-
export const Greet = Action.make("greet", {
|
|
15
|
+
```ts
|
|
16
|
+
const Greet = Action.make("greet", {
|
|
22
17
|
description: "Greet someone by name.",
|
|
23
18
|
input: { name: Schema.String },
|
|
24
19
|
success: Schema.String,
|
|
@@ -29,43 +24,27 @@ export const Greet = Action.make("greet", {
|
|
|
29
24
|
|
|
30
25
|
An implementation binds it to its handler:
|
|
31
26
|
|
|
32
|
-
```ts
|
|
33
|
-
|
|
34
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
35
|
-
import { Greet } from "./Greeting.js";
|
|
36
|
-
|
|
37
|
-
// The implementation: the contract bound to its handler. Every surface takes this one value.
|
|
38
|
-
export const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
|
|
27
|
+
```ts
|
|
28
|
+
const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
|
|
39
29
|
```
|
|
40
30
|
|
|
41
31
|
A binding gives it a route, for the server to serve and a client to call:
|
|
42
32
|
|
|
43
|
-
```ts
|
|
44
|
-
|
|
45
|
-
import { Greet } from "./greeting/Greeting.js";
|
|
46
|
-
|
|
47
|
-
// The HTTP binding: `POST /api/greet`. The server serves it and the client calls it.
|
|
48
|
-
export const Http = ActionHttp.make([Greet]);
|
|
33
|
+
```ts
|
|
34
|
+
const Http = ActionHttp.make([Greet]); // POST /api/greet
|
|
49
35
|
```
|
|
50
36
|
|
|
51
37
|
And the same implementation is served on every surface the host wants:
|
|
52
38
|
|
|
53
|
-
```ts
|
|
54
|
-
|
|
55
|
-
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
56
|
-
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
57
|
-
import { Http } from "./Api.js";
|
|
58
|
-
import { greeting } from "./greeting/handlers.js";
|
|
59
|
-
|
|
60
|
-
// One implementation, two surfaces: a JSON route and an MCP tool at `/mcp`.
|
|
61
|
-
export const routes = Layer.mergeAll(
|
|
39
|
+
```ts
|
|
40
|
+
const routes = Layer.mergeAll(
|
|
62
41
|
ActionHttp.layer(Http, greeting),
|
|
63
42
|
ActionMcp.layerHttp(greeting, { name: "greetings", version: "1.0.0" }),
|
|
64
43
|
);
|
|
65
44
|
```
|
|
66
45
|
|
|
67
46
|
Serve `routes` with Effect's `HttpRouter.serve` and a platform server, as
|
|
68
|
-
[examples/quickstart
|
|
47
|
+
[examples/quickstart](examples/quickstart) does, file by file, and you have
|
|
69
48
|
`POST /api/greet` and an MCP tool named `greet` at `/mcp`. The same `greeting`
|
|
70
49
|
implementation is also:
|
|
71
50
|
|
|
@@ -82,10 +61,10 @@ const rpc = ActionRpc.layer(ActionRpc.make([Greet]), greeting); // Effect RPC, o
|
|
|
82
61
|
And the client is Effect's own `HttpApiClient`, typed from the same contract, one method per action:
|
|
83
62
|
|
|
84
63
|
```ts
|
|
85
|
-
|
|
64
|
+
Effect.gen(function* () {
|
|
86
65
|
const client = yield* ActionHttp.client(Http, { baseUrl });
|
|
87
66
|
|
|
88
|
-
return yield* client.greet({ name: "Ada" });
|
|
67
|
+
return yield* client.greet({ name: "Ada" }); // "Hello, Ada!"
|
|
89
68
|
});
|
|
90
69
|
```
|
|
91
70
|
|
|
@@ -95,7 +74,7 @@ const greeting = Effect.gen(function* () {
|
|
|
95
74
|
- A handler can fail only with the errors its action declares and three built-in ones (400, 401, 403), and every client decodes them as typed values.
|
|
96
75
|
- Every contract states who may call it: `caller: Action.Anyone`, or the identity service a caller must have. A surface authenticates a protected action's request before decoding its input, with the provider the application builds from its own token verifier.
|
|
97
76
|
- Authorization is written once, on the implementation: `Action.implement(actions, handlers, { authorize })`, required for protected actions. Every surface runs it before each handler, and the rule reads the contract's `readOnly` instead of a hand-maintained list of mutation names.
|
|
98
|
-
- Public and protected routes share one binding, one OpenAPI document and one client, and a handler's startup services are built once per [layer graph](docs/Action.md#
|
|
77
|
+
- Public and protected routes share one binding, one OpenAPI document and one client, and a handler's startup services are built once per [layer graph](docs/Action.md#share-one-build-across-servers), however many surfaces serve it.
|
|
99
78
|
- The pieces are Effect's own: a native `HttpApi`, `McpServer`, `RpcServer` and `RpcClient`, with no SDK runtime dependency, so OpenAPI, Swagger, Scalar and `HttpApiClient` work unchanged.
|
|
100
79
|
- Tests run in memory: `Action.client` calls an implementation in process, and `Testing.layer(routes)` serves the routes and tools to typed clients without opening a port.
|
|
101
80
|
|
|
@@ -106,7 +85,7 @@ pnpm add @gjermundgaraba/effect-actions effect@~4.0.4
|
|
|
106
85
|
```
|
|
107
86
|
|
|
108
87
|
It needs TypeScript 7 or newer and is ESM only
|
|
109
|
-
([docs/setup.md](docs/setup.md#install)). Add `@effect/platform-node@~4.0.4` to serve or run a CLI from Node. Every module is a
|
|
88
|
+
([docs/setup.md](docs/setup.md#install)). Add `@effect/platform-node@~4.0.4` to serve or run a CLI from Node, and `@types/node` for its `node:` imports. Every module is a
|
|
110
89
|
subpath import (`.../Action`, `.../ActionHttp`, ...); there is no package root, so a
|
|
111
90
|
contracts-only bundle never loads a server.
|
|
112
91
|
|
|
@@ -130,13 +109,8 @@ type-checked and tested with the library and ship in the package.
|
|
|
130
109
|
## Docs
|
|
131
110
|
|
|
132
111
|
[docs/](docs/README.md) is written for coding agents: one short page per module, organized by
|
|
133
|
-
task, each linking to the example files that show the task working.
|
|
134
|
-
|
|
135
|
-
- [docs/README.md](docs/README.md): start here: which page answers which task, and which
|
|
136
|
-
surface to use for which caller.
|
|
137
|
-
- [docs/guarantees.md](docs/guarantees.md): what each case looks like on the wire, on every
|
|
138
|
-
surface, and what the library leaves to the application.
|
|
139
|
-
- [docs/CONTEXT.md](docs/CONTEXT.md): the vocabulary the docs and the code use.
|
|
112
|
+
task, each linking to the example files that show the task working. Start at
|
|
113
|
+
[docs/README.md](docs/README.md).
|
|
140
114
|
|
|
141
115
|
## For agents
|
|
142
116
|
|
|
@@ -169,9 +143,8 @@ the pages of the installed version are beside them, in
|
|
|
169
143
|
|
|
170
144
|
## Status
|
|
171
145
|
|
|
172
|
-
The `effect` peer
|
|
173
|
-
|
|
174
|
-
Token verification and the authorization rule belong to the application; the library supplies the authentication descriptor and provider, runs the rule, and answers the refusals. See [docs/guarantees.md](docs/guarantees.md#scope).
|
|
146
|
+
The `effect` peer range is in [docs/setup.md](docs/setup.md#install). Actions are unary JSON over HTTP, MCP and Effect RPC, whose protocols speak JSON alone: no streaming or uploads.
|
|
147
|
+
Token verification and the authorization rule belong to the application; the library supplies the authentication descriptor and provider, runs the rule, and answers the refusals. See [what the library leaves to you](docs/README.md#what-the-library-leaves-to-you).
|
|
175
148
|
|
|
176
149
|
## Acknowledgements
|
|
177
150
|
|
package/dist/Action.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Anyone } from "./contract/caller.mjs";
|
|
2
|
-
import { BuiltIn,
|
|
2
|
+
import { BuiltIn, Forbidden, InvalidInput, Issue, Refusal, Unauthenticated } from "./contract/errors.mjs";
|
|
3
3
|
import { ActionOf, AnyImplementation, Authorize, BuildError, BuildServices, Holding, Implementation, Known, Member, Offered, OptionalUnless, Protected, RequestOf, SelectedOf, Served, Serving } from "./contract/implementation.mjs";
|
|
4
4
|
import { Call } from "./contract/call.mjs";
|
|
5
5
|
import { Image } from "./contract/media.mjs";
|
|
@@ -40,28 +40,64 @@ export interface Mcp {
|
|
|
40
40
|
export interface Options {
|
|
41
41
|
/**
|
|
42
42
|
* Who may call the action: `Action.Anyone`, or the identity service a caller must have,
|
|
43
|
-
* which every surface enforces. Required: an action that
|
|
43
|
+
* which every surface enforces whether or not the handler reads it. Required: an action that
|
|
44
|
+
* names no caller is not public. A remote surface gets the identity by authenticating the
|
|
45
|
+
* request; on a local surface the host provides it.
|
|
46
|
+
*
|
|
47
|
+
* **Gotchas**
|
|
48
|
+
*
|
|
49
|
+
* - Declare the identity as a `Context.Service`, in a contract file, apart from its verifier.
|
|
50
|
+
* - One identity per action, and every protected action one remote surface serves names the same one; a tenant is a request-time requirement of the handler, not a second identity.
|
|
51
|
+
* - No surface provides a public action an identity: an action whose answer depends on the caller is protected.
|
|
52
|
+
* - Never provide an identity at startup, to a surface's layer: a call no authentication reaches, a public action's or a local surface's, runs as that identity, and the types do not stop it.
|
|
44
53
|
*/
|
|
45
|
-
readonly caller: typeof Anyone | Context.Key<unknown, unknown
|
|
54
|
+
readonly caller: typeof Anyone | (Context.Key<unknown, unknown> & {
|
|
55
|
+
readonly defaultValue?: "An identity is a Context.Service, not a Context.Reference";
|
|
56
|
+
});
|
|
57
|
+
/** A model reads it beside the tool; a CLI prints it in `--help`. */
|
|
46
58
|
readonly description: string;
|
|
47
59
|
/**
|
|
48
60
|
* A schema or struct fields. Omit, or give `{}`, for an action without arguments. A field's
|
|
49
61
|
* `description` annotation is what a model reads in a tool's listing and a flag's help in a CLI.
|
|
62
|
+
*
|
|
63
|
+
* **Gotchas**
|
|
64
|
+
*
|
|
65
|
+
* - Never `Schema.Struct({})` for no arguments: it accepts any value but `null` and `undefined`, and MCP refuses it as a tool's input. Omit `input`: the action then takes `{}`.
|
|
66
|
+
* - A field callers may omit is `Schema.optional`: on the wire `null` is also accepted and read as left out, as a tool's schema lists, while a typed client refuses `null`. `Schema.optionalKey` takes omission alone; `Schema.NullOr` keeps `null` as a value.
|
|
67
|
+
* - A field with a decoding default, `Schema.withDecodingDefault`, is still required in a typed client's argument, whose type is the decoded one; the argument itself may be left out only when every field has one, as `{}` then encodes.
|
|
68
|
+
* - An input that is a class encodes only its instances: a typed client given a plain object fails with `InvalidInput`, `Expected <Class>`.
|
|
69
|
+
* - A `Schema.makeFilter` check adds nothing to a tool's listed input schema, and `Schema.isPattern` its `pattern` only for a RegExp with the `u` flag: say such a rule in the field's `description`, annotated before the check, since after a custom one it is lost too.
|
|
50
70
|
*/
|
|
51
71
|
readonly input?: Codec | Fields | undefined;
|
|
52
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* A schema or struct fields. Omit for an action that returns nothing, `Schema.Void`; what each
|
|
74
|
+
* surface answers is on its `layer`, `layerHttp`, `runStdio`, `make` or `command`. A top-level field whose schema is
|
|
75
|
+
* `Action.Image` is a media field: see `Action.Image`.
|
|
76
|
+
*/
|
|
53
77
|
readonly success?: Codec | Fields | undefined;
|
|
54
78
|
/**
|
|
55
79
|
* The declared failures: one schema, or a list of them, as `HttpApiEndpoint` takes them;
|
|
56
80
|
* each keeps its own HTTP status annotation, the third argument of
|
|
57
|
-
* `Schema.TaggedError<NoteNotFound>()("NoteNotFound", { id: Schema.String }, { httpApiStatus: 404 })
|
|
58
|
-
*
|
|
81
|
+
* `Schema.TaggedError<NoteNotFound>()("NoteNotFound", { id: Schema.String }, { httpApiStatus: 404 })`.
|
|
82
|
+
* Defaults to none. A handler fails only with these and the built-in errors; every client
|
|
83
|
+
* decodes each as a typed value.
|
|
84
|
+
*
|
|
85
|
+
* **Gotchas**
|
|
86
|
+
*
|
|
87
|
+
* - Over HTTP a declared error answers with its `httpApiStatus`, 422 without one; what the other surfaces answer is on their `layerHttp`, `runStdio`, `layer`, `make` or `command`.
|
|
88
|
+
* - Two schemas sharing a `_tag` throw at `make`, `error _tag "<tag>" names two error schemas`, or at a binding's `make`, `binding of "<name>"`, for one of its errors against the action's or the authentication's; the message says the fix.
|
|
89
|
+
* - A schema reached twice, listed again, through a union or `Schema.suspend`, is one schema; a union is its members unless it has its own `httpApiStatus`, checks or a transformation, so each member's `_tag` is checked against every other error's.
|
|
90
|
+
* - Errors without a `_tag` are told apart by their other fields, the first that decodes winning: a failure an earlier schema also accepts is decoded as that one.
|
|
91
|
+
* - An entry that is a `Schema.suspend` whose thunk reads a `const` declared further down throws a `ReferenceError` at `make`, which reads every `_tag` when called: declare the error first.
|
|
92
|
+
* - A failure the schemas reject, undeclared or breaking a check, is the call's defect on every surface, beside the `SchemaError` saying why; a class error built with `disableChecks` is accepted and fails to encode instead.
|
|
59
93
|
*/
|
|
60
94
|
readonly error?: Codec | ReadonlyArray<Codec> | undefined;
|
|
61
95
|
/**
|
|
62
96
|
* Whether the action leaves its resource unchanged. Required: an action nobody classified
|
|
63
97
|
* is the one a reviewer must check. An implementation's `authorize` may read it, and MCP's
|
|
64
|
-
* `readOnlyHint` is it; the library itself authorizes nothing with it.
|
|
98
|
+
* `readOnlyHint` is it; the library itself authorizes nothing with it. Derive authorization
|
|
99
|
+
* from it, never from an `mcp` hint: changing an action's `readOnly` then changes who may
|
|
100
|
+
* call it.
|
|
65
101
|
*/
|
|
66
102
|
readonly readOnly: boolean;
|
|
67
103
|
/** How the action's tool presents itself over MCP, and on a native Toolkit's tools. */
|
|
@@ -74,24 +110,16 @@ type KnownMcp<O> = {
|
|
|
74
110
|
readonly mcp?: { readonly [K in UnknownMcp<O>]: never; };
|
|
75
111
|
};
|
|
76
112
|
type ErrorMembers<G> = G extends ReadonlyArray<infer E> ? E : G;
|
|
77
|
-
type OwnErrors<O> = O extends {
|
|
78
|
-
readonly error: infer G;
|
|
79
|
-
} ? [Extract<ErrorMembers<G>, BuiltIns>] extends [never] ? unknown : {
|
|
80
|
-
readonly error: Exclude<ErrorMembers<G>, BuiltIns> | ReadonlyArray<Exclude<ErrorMembers<G>, BuiltIns>>;
|
|
81
|
-
} : unknown;
|
|
82
113
|
type OnlyOptions<O> = { readonly [K in Exclude<O extends unknown ? keyof O : never, keyof Options>]: never; };
|
|
83
|
-
type
|
|
84
|
-
readonly caller: {
|
|
85
|
-
readonly defaultValue: unknown;
|
|
86
|
-
};
|
|
87
|
-
} ? {
|
|
88
|
-
readonly caller: "An identity is a Context.Service, not a Context.Reference";
|
|
89
|
-
} : unknown;
|
|
90
|
-
type Rules<O> = Options extends O ? unknown : OnlyOptions<O> & ServiceCaller<O> & OwnErrors<O> & KnownMcp<O>;
|
|
114
|
+
type Rules<O> = Options extends O ? unknown : OnlyOptions<O> & KnownMcp<O>;
|
|
91
115
|
type OptionOf<O, K extends keyof Options, Default> = O extends unknown ? K extends keyof O ? Exclude<O[K], undefined> | ({} extends Pick<O, K> ? Default : undefined extends O[K] ? Default : never) : Default : never;
|
|
92
116
|
type SchemaOf<O, K extends "input" | "success", Default extends Codec> = CodecOf<Extract<OptionOf<O, K, Default>, Codec | Fields>>;
|
|
93
117
|
type ErrorSchemasOf<O> = ReadonlyArray<ErrorMembers<Extract<OptionOf<O, "error", []>, Codec | ReadonlyArray<Codec>>>>;
|
|
94
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* A pure contract: schemas and transport metadata, bound to handlers by `implement`. Its
|
|
120
|
+
* `name`, `input`, `success`, `error` and `readOnly` are public: reuse them, as in
|
|
121
|
+
* `Schema.Array(SaveLink.input)`.
|
|
122
|
+
*/
|
|
95
123
|
export interface Action<Name extends string, Input extends Codec, Success extends Codec, Errors extends ReadonlyArray<Codec>, ReadOnly extends boolean = boolean, Caller extends Options["caller"] = Options["caller"]> {
|
|
96
124
|
readonly caller: Caller;
|
|
97
125
|
readonly name: Name;
|
|
@@ -124,9 +152,16 @@ export type Handler<A extends Any, R = never> = (input: A["input"]["Type"]) => E
|
|
|
124
152
|
*/
|
|
125
153
|
export type Handlers<A extends ReadonlyArray<Any>, R = never> = { readonly [Action in A[number] as Action["name"]]: Handler<Action, R>; };
|
|
126
154
|
/**
|
|
127
|
-
* Define an action contract
|
|
128
|
-
* is also the route segment, the client method and the tool name.
|
|
155
|
+
* Define an action contract: what it takes and answers, whether it changes anything, and who
|
|
156
|
+
* may call it. The name is also the route segment, the client method and the tool name.
|
|
129
157
|
* Guide: `docs/Action.md` in this package; in an application: `examples/notes-service/src/notes/`.
|
|
158
|
+
*
|
|
159
|
+
* **Gotchas**
|
|
160
|
+
*
|
|
161
|
+
* - A name is unique within each binding, MCP endpoint, Toolkit, CLI and client holding it; its form is in the message `Invalid action name` throws.
|
|
162
|
+
* - A contract says nothing about where it is served: an HTTP or RPC binding, or a surface given `actions`, serves an action only once it is listed; MCP, a Toolkit and a CLI given implementations alone serve every action they hold.
|
|
163
|
+
* - Surfaces match an implementation to a contract by object identity, never by name: implement, bind and list the one exported value. A list narrowed at run time is still typed as holding every member.
|
|
164
|
+
* - In a helper whose type parameter stands for `mcp`, `make` refuses the option, since it cannot check the hints: type the parameter `Action.Mcp`, or spread a constant.
|
|
130
165
|
*/
|
|
131
166
|
export declare function make<const Name extends string, const O extends Options>(name: Name, options: O & NoInfer<Rules<O>>): Action<Name, SchemaOf<O, "input", typeof NoInput>, SchemaOf<O, "success", typeof Schema.Void>, ErrorSchemasOf<O>, O["readOnly"], O["caller"]>;
|
|
132
167
|
/**
|
|
@@ -152,21 +187,25 @@ type HandlersOf<T extends Target, R, RequestServices> = Single<T, R> | Several<T
|
|
|
152
187
|
type Deferred<T> = [T][T extends unknown ? 0 : never];
|
|
153
188
|
type Authorizer<A extends Any, RA, EAX, RAX> = ((action: A) => Effect.Effect<void, Refusal, RA>) | Effect.Effect<(action: A) => Effect.Effect<void, Refusal, RA>, EAX, RAX>;
|
|
154
189
|
type Implemented<T extends Target, RequestServices, R, EX, RX, RA, EAX, RAX> = Implementation<ActionsOf<T>, { readonly [K in NamesOf<T>]: Exclude<T extends ReadonlyArray<Any> ? RequestServices[K & keyof RequestServices] : R, Scope.Scope>; }, Deferred<EX>, Deferred<Exclude<RX, Scope.Scope>>, Exclude<RA, Scope.Scope>, Deferred<EAX>, Deferred<Exclude<RAX, Scope.Scope>>>;
|
|
155
|
-
/**
|
|
156
|
-
* Every authenticated caller may call a protected action. The contract's identity
|
|
157
|
-
* requirement is enforced independently, including when its handler reads no identity.
|
|
158
|
-
*/
|
|
190
|
+
/** Every authenticated caller may call a protected action. */
|
|
159
191
|
export declare const allowAll: Authorize<Any>;
|
|
160
192
|
/**
|
|
161
|
-
* Bind handlers to contracts, behind authorization
|
|
162
|
-
* actions and a record of handlers keyed by action name
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
193
|
+
* Bind handlers to contracts, behind authorization: one action and its handler, or a list of
|
|
194
|
+
* actions and a record of handlers keyed by action name, given directly or built by an Effect
|
|
195
|
+
* whose services are startup requirements, while what a handler yields is a request-time
|
|
196
|
+
* requirement. `authorize` is required when the list holds a protected contract and runs only
|
|
197
|
+
* for those.
|
|
198
|
+
*
|
|
199
|
+
* **Gotchas**
|
|
166
200
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
201
|
+
* - A builder runs once per layer graph, however many surfaces serve its implementation; two `implement` calls are two builders, even of one builder Effect.
|
|
202
|
+
* - What the builder yields is shared by every call; what a handler yields is resolved per call, as the caller must be. A builder yielding the caller is refused where the server is launched.
|
|
203
|
+
* - Each call has a scope of its own: a resource that must outlive a call is the builder's.
|
|
204
|
+
* - Pass the builder to `implement` itself: through `.pipe(...)` or a wrapper such as `Effect.withSpan`, an `Effect.fn` handler it returns takes an `any` input. A builder written apart returns its record `satisfies Action.Handlers<typeof actions>`.
|
|
205
|
+
* - A record holds one own function per action of the list and no other key, or `implement` throws, a builder's when its layer builds; an inherited method does not count.
|
|
206
|
+
* - Calling the function you passed to `implement` skips authorization and the codecs: call the implementation through `Action.client`.
|
|
207
|
+
* - What a plain `authorize` yields is a request-time requirement of every surface; to read a store once, make `authorize` an Effect that yields it and returns the function.
|
|
208
|
+
* - An authorizer fails only with `Unauthenticated` or `Forbidden`; a limit callers should see, such as a rate limit, is the handler's, declared in the action's `error`.
|
|
170
209
|
*/
|
|
171
210
|
export declare function implement<const T extends Target, RequestServices extends (T extends ReadonlyArray<Any> ? { readonly [K in NamesOf<T>]: unknown; } : never), R = never, EX = never, RX = never, RA = never, EAX = never, RAX = never>(actions: T, handlers: HandlersOf<T, R, RequestServices> | Effect.Effect<HandlersOf<T, R, RequestServices>, EX, RX>, options: [Protected<ActionsOf<T>>] extends [never] ? {
|
|
172
211
|
readonly authorize?: "A public-only target takes no authorize";
|
|
@@ -177,8 +216,14 @@ export declare function implement<const T extends Target, RequestServices extend
|
|
|
177
216
|
/**
|
|
178
217
|
* The builders of `implementations`, as one layer providing nothing. Surfaces build them
|
|
179
218
|
* themselves, once per layer graph, but routes under `HttpRouter.serve` or `Testing.layer`
|
|
180
|
-
* are built in a graph of their own
|
|
181
|
-
* surface, with the services it is given.
|
|
219
|
+
* are built in a graph of their own, sharing nothing they build: provided above the server and
|
|
220
|
+
* its siblings, it runs each builder once for every surface, with the services it is given.
|
|
221
|
+
*
|
|
222
|
+
* **Gotchas**
|
|
223
|
+
*
|
|
224
|
+
* - It builds every implementation it is given, those no surface serves included, so it owes their startup services.
|
|
225
|
+
* - A job merged beside `HttpRouter.serve` with no `Action.layer` above both shares the server's build only when its own side builds first, which depends on timing: put the job inside the layer the server serves, or provide this layer above both.
|
|
226
|
+
* - Do not wrap a server in `Layer.fresh`: two servers already share no build. It is for two surfaces in one graph that must each have a build of their own; provide what they share, authentication included, outside it.
|
|
182
227
|
*/
|
|
183
228
|
export declare function layer<const Apps extends Served>(implementations: Apps): Layer.Layer<never, BuildError<Member<Apps>>, BuildServices<Member<Apps>>>;
|
|
184
229
|
type Method<A extends Any, R> = Call<A, Effect.Effect<A["success"]["Type"], A["error"][number]["Type"] | BuiltIn, R>>;
|
|
@@ -196,18 +241,22 @@ export interface ClientOptions<A extends Any> {
|
|
|
196
241
|
readonly actions?: ReadonlyArray<A> | undefined;
|
|
197
242
|
}
|
|
198
243
|
/**
|
|
199
|
-
* Call implementations in process: one method per action, taking its input directly
|
|
244
|
+
* Call implementations in process: one method per action, taking its input directly as
|
|
200
245
|
* `ActionHttp.client`'s methods do, `client.renameUser({ id, name })`, so moving between the
|
|
201
|
-
* two changes the line acquiring it. A call runs as a remote one does
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
246
|
+
* two changes the line acquiring it. A call runs as a remote one does, its input and its answer
|
|
247
|
+
* through their JSON codecs, the implementation's `authorize` and the handler in a scope of
|
|
248
|
+
* their own, failing with the action's errors and the built-in ones. Acquiring it builds the
|
|
249
|
+
* implementations' builders into the layer graph it is acquired in, as a layer does, and its
|
|
250
|
+
* scope holds them: acquire it where builders live.
|
|
251
|
+
*
|
|
252
|
+
* **Gotchas**
|
|
206
253
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
254
|
+
* - Provide the caller around each call, and startup services around the acquisition: a call reads its own context, never the one the client was acquired in, so one client serves every caller.
|
|
255
|
+
* - Acquiring it needs a `Scope`: `it.effect` supplies one, elsewhere `Effect.scoped` does; acquired in a handler, it builds on every request.
|
|
256
|
+
* - Two acquisitions share a build only inside one layer graph, such as under a layer provided around the program; with none around them, each builds its own.
|
|
257
|
+
* - Every implemented action has a method, whether or not a surface serves it; `actions` lists the ones to call, and an implementation holding none of them is left unbuilt.
|
|
258
|
+
* - It drops undeclared input fields as it encodes, and fails with `InvalidInput`, running nothing, for an input that does not encode.
|
|
259
|
+
* - A builder acquiring a client of its own implementation is refused by the compiler, TS7022, and with the types bypassed never completes: call the services both use instead.
|
|
211
260
|
*/
|
|
212
261
|
export declare function client<const Apps extends Served, const O extends ClientOptions<ActionOf<Member<Apps>>> = {}>(implementations: Apps, ...options: OptionalUnless<O, ClientOptions<ActionOf<Member<Apps>>> & O & NoInfer<Known<O, ClientOptions<Any>>>>): Effect.Effect<Client<Apps, Offered<O, ActionOf<Member<Apps>>>>, BuildError<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>, BuildServices<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>> | Scope.Scope>;
|
|
213
262
|
//#endregion
|
package/dist/Action.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Anyone } from "./contract/caller.mjs";
|
|
2
2
|
import { BuiltIn, Forbidden, InvalidInput, Refusal, Unauthenticated } from "./contract/errors.mjs";
|
|
3
|
-
import { assertKnown, assertName, assertOnce } from "./contract/names.mjs";
|
|
4
|
-
import {
|
|
3
|
+
import { assertKnown, assertName, assertOnce, heldOnce } from "./contract/names.mjs";
|
|
4
|
+
import { assertDistinctTags, errorList, projectedErrors } from "./contract/declared.mjs";
|
|
5
5
|
import { Implementation, builders, built, memoized, select, toList } from "./contract/implementation.mjs";
|
|
6
6
|
import { inputOf } from "./contract/call.mjs";
|
|
7
7
|
import { Image } from "./contract/media.mjs";
|
|
@@ -10,11 +10,11 @@ import { Array as Array$1, Cause, Context, Effect, Predicate, Schema } from "eff
|
|
|
10
10
|
const NoInput = Schema.Record(Schema.String, Schema.Never);
|
|
11
11
|
const codecOf = (schema) => Schema.isSchema(schema) ? schema : Reflect.ownKeys(schema).length === 0 ? NoInput : Schema.Struct(schema);
|
|
12
12
|
function make(name, options) {
|
|
13
|
-
assertName(
|
|
13
|
+
assertName(name);
|
|
14
14
|
const { caller, readOnly } = options;
|
|
15
15
|
if (caller !== Anyone && !Context.isKey(caller)) throw new Error("Missing caller: declare Action.Anyone or an identity service key");
|
|
16
16
|
if (caller !== Anyone && Context.isReference(caller)) throw new Error("Invalid caller: an identity is a Context.Service, not a Context.Reference");
|
|
17
|
-
if (readOnly !== true && readOnly !== false) throw new Error(`Invalid readOnly: ${String(readOnly)}`);
|
|
17
|
+
if (readOnly !== true && readOnly !== false) throw new Error(`Invalid readOnly: ${String(readOnly)} (set true or false)`);
|
|
18
18
|
const action = {
|
|
19
19
|
caller,
|
|
20
20
|
name,
|
|
@@ -25,20 +25,17 @@ function make(name, options) {
|
|
|
25
25
|
readOnly,
|
|
26
26
|
mcp: { ...options.mcp }
|
|
27
27
|
};
|
|
28
|
-
|
|
28
|
+
assertDistinctTags(`Action "${action.name}"`, projectedErrors(action));
|
|
29
29
|
return action;
|
|
30
30
|
}
|
|
31
31
|
function byName(actions) {
|
|
32
32
|
assertOnce("action", actions);
|
|
33
33
|
return Object.fromEntries(actions.map((action) => [action.name, action]));
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Every authenticated caller may call a protected action. The contract's identity
|
|
37
|
-
* requirement is enforced independently, including when its handler reads no identity.
|
|
38
|
-
*/
|
|
35
|
+
/** Every authenticated caller may call a protected action. */
|
|
39
36
|
const allowAll = () => Effect.void;
|
|
40
37
|
const assertAuthorizer = (before) => {
|
|
41
|
-
if (!Predicate.isFunction(before)) throw new Error("Missing authorize: pass
|
|
38
|
+
if (!Predicate.isFunction(before)) throw new Error("Missing authorize: not a function (pass your rule, Action.allowAll, or an Effect returning one)");
|
|
42
39
|
return before;
|
|
43
40
|
};
|
|
44
41
|
const assertAuthorization = (authorize) => {
|
|
@@ -46,7 +43,7 @@ const assertAuthorization = (authorize) => {
|
|
|
46
43
|
return authorize;
|
|
47
44
|
};
|
|
48
45
|
const assertNoAuthorization = (authorize) => {
|
|
49
|
-
if (authorize !== void 0) throw new Error("A public-only target takes no authorize");
|
|
46
|
+
if (authorize !== void 0) throw new Error("A public-only target takes no authorize (remove it, or list a protected action)");
|
|
50
47
|
return allowAll;
|
|
51
48
|
};
|
|
52
49
|
const authorizerOf = (before) => memoized(Effect.isEffect(before) ? Effect.map(before, assertAuthorizer) : Effect.succeed(assertAuthorizer(before)));
|
|
@@ -57,13 +54,13 @@ function implement(actions, handlers, options) {
|
|
|
57
54
|
const authorizer = authorizerOf(listed.some((action) => action.caller !== Anyone) ? assertAuthorization(options?.authorize) : assertNoAuthorization(options?.authorize));
|
|
58
55
|
const pairWithActions = (built) => {
|
|
59
56
|
const keyed = Predicate.isFunction(built) ? Object.fromEntries(Array.isArray(actions) ? [] : listed.map(({ name }) => [name, built])) : built;
|
|
60
|
-
assertKnown("handlers", Object.keys(keyed), names);
|
|
57
|
+
assertKnown("handlers", Object.keys(keyed), names, "remove it, or list its action");
|
|
61
58
|
const bound = listed.flatMap((action) => {
|
|
62
59
|
const handle = Object.hasOwn(keyed, action.name) ? keyed[action.name] : void 0;
|
|
63
60
|
return Predicate.isFunction(handle) ? [[action, handle]] : [];
|
|
64
61
|
});
|
|
65
62
|
const missing = names.filter((name) => !bound.some(([action]) => action.name === name));
|
|
66
|
-
if (missing.length > 0) throw new Error(`Missing handlers: ${missing.join(", ")}`);
|
|
63
|
+
if (missing.length > 0) throw new Error(`Missing handlers: ${missing.join(", ")} (return the handler for one action, or a record with an own function per listed action)`);
|
|
67
64
|
return bound;
|
|
68
65
|
};
|
|
69
66
|
const recorded = Effect.isEffect(handlers) ? Effect.map(handlers, pairWithActions) : Effect.succeed(pairWithActions(handlers));
|
|
@@ -90,7 +87,7 @@ const methodOf = (action, run) => {
|
|
|
90
87
|
};
|
|
91
88
|
function client(served, options) {
|
|
92
89
|
const apps = select(toList(served), options?.actions);
|
|
93
|
-
assertOnce("action", apps.flatMap((app) => app.actions));
|
|
90
|
+
assertOnce("action", apps.flatMap((app) => app.actions), heldOnce);
|
|
94
91
|
return Effect.map(built(apps), (bound) => Object.fromEntries(bound.map(([action, run]) => [action.name, methodOf(action, run)])));
|
|
95
92
|
}
|
|
96
93
|
//#endregion
|