@gjermundgaraba/effect-actions 0.1.0-rc.0 → 0.1.0-rc.1
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 +153 -48
- package/dist/Action-CXsfTEhx.d.mts +54 -0
- package/dist/Action.d.mts +2 -54
- package/dist/Action.mjs +3 -7
- package/dist/ActionGroup.d.mts +19 -11
- package/dist/ActionGroup.mjs +17 -19
- package/dist/ActionHttp.d.mts +52 -0
- package/dist/ActionHttp.mjs +74 -0
- package/dist/ActionMcp.d.mts +20 -0
- package/dist/ActionMcp.mjs +133 -0
- package/dist/Authentication.d.mts +47 -0
- package/dist/Authentication.mjs +88 -0
- package/dist/Testing.d.mts +33 -0
- package/dist/Testing.mjs +40 -0
- package/dist/{testing/client.d.mts → TestingClient.d.mts} +3 -1
- package/dist/{testing/client.mjs → TestingClient.mjs} +4 -5
- package/dist/implementation-4Q2BESw5.mjs +61 -0
- package/dist/implementation-DTqp_0DW.d.mts +61 -0
- package/package.json +10 -10
- package/dist/authentication.d.mts +0 -27
- package/dist/authentication.mjs +0 -31
- package/dist/http.d.mts +0 -53
- package/dist/http.mjs +0 -74
- package/dist/implementation-BkLqMWtv.mjs +0 -32
- package/dist/implementation-D75QAcD3.d.mts +0 -21
- package/dist/index.d.mts +0 -6
- package/dist/index.mjs +0 -6
- package/dist/mcp.d.mts +0 -46
- package/dist/mcp.mjs +0 -189
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
- package/dist/testing.d.mts +0 -10
- package/dist/testing.mjs +0 -34
package/README.md
CHANGED
|
@@ -2,17 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
Define an Effect action once, implement it once, expose it over **HTTP and MCP**.
|
|
4
4
|
|
|
5
|
-
**
|
|
6
|
-
[`
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
**Snapshot-dependent preview.** This release requires Effect snapshot
|
|
6
|
+
[`9ad9891`](https://pkg.pr.new/Effect-TS/effect/effect@9ad9891). The published npm
|
|
7
|
+
version `effect@4.0.0-rc.115` is not compatible: it lacks APIs required by the MCP
|
|
8
|
+
adapter. Install both packages explicitly:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm add @gjermundgaraba/effect-actions@rc \
|
|
12
|
+
'effect@https://pkg.pr.new/Effect-TS/effect/effect@9ad9891'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The snapshot reports version `4.0.0-rc.115`, so the package retains that nominal
|
|
16
|
+
peer declaration. A semver peer cannot distinguish the snapshot from the npm
|
|
17
|
+
build; satisfying the peer declaration alone does **not** ensure compatibility.
|
|
18
|
+
Installation requires access to `pkg.pr.new`, not just the npm registry.
|
|
19
|
+
|
|
20
|
+
For Node server wiring, use the matching platform snapshot too:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm add '@effect/platform-node@https://pkg.pr.new/Effect-TS/effect/@effect/platform-node@9ad9891'
|
|
24
|
+
```
|
|
10
25
|
|
|
11
26
|
## Quickstart
|
|
12
27
|
|
|
13
28
|
```ts
|
|
14
29
|
import { Effect, Layer, Schema } from "effect";
|
|
15
|
-
import
|
|
30
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
31
|
+
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
32
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
33
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
16
34
|
|
|
17
35
|
const Greet = Action.make("greet", {
|
|
18
36
|
description: "Greet someone by name.",
|
|
@@ -21,36 +39,42 @@ const Greet = Action.make("greet", {
|
|
|
21
39
|
mcp: { readOnly: true },
|
|
22
40
|
});
|
|
23
41
|
|
|
24
|
-
export const Actions = ActionGroup.make(Greet);
|
|
42
|
+
export const Actions = ActionGroup.make({ name: "greetings" }, Greet);
|
|
43
|
+
|
|
44
|
+
export const Http = ActionHttp.make({ apiPath: "/api/actions" }, Actions);
|
|
45
|
+
|
|
25
46
|
const app = Actions.implement({
|
|
26
47
|
greet: ({ name }) => Effect.succeed(`Hello, ${name}!`),
|
|
27
48
|
});
|
|
28
49
|
|
|
29
50
|
export const routes = Layer.mergeAll(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
openapiPath: "/openapi.json",
|
|
33
|
-
}),
|
|
34
|
-
ActionMcp.layer(app, { name: "greetings", version: "1.0.0", path: "/mcp" }),
|
|
51
|
+
Http.layer(app),
|
|
52
|
+
ActionMcp.layer({ name: "greetings", version: "1.0.0", path: "/mcp" }, app),
|
|
35
53
|
);
|
|
36
54
|
```
|
|
37
55
|
|
|
38
|
-
Serve `routes` with Effect's `HttpRouter`. This creates `POST /api/actions/greet
|
|
39
|
-
|
|
40
|
-
`
|
|
56
|
+
Serve `routes` with Effect's `HttpRouter`. This creates `POST /api/actions/greet` and an
|
|
57
|
+
MCP endpoint at `/mcp`. The HTTP response is `"Hello, Ada!"`; MCP returns
|
|
58
|
+
`structuredContent: { value: "Hello, Ada!" }`.
|
|
41
59
|
See [examples/server.ts](examples/server.ts) for Node server wiring and the
|
|
42
60
|
[authenticated demo](examples/README.md) for a runnable application.
|
|
43
61
|
|
|
44
62
|
### Contracts
|
|
45
63
|
|
|
46
|
-
- `input`
|
|
47
|
-
- `
|
|
64
|
+
- Omit `input` for an action without arguments.
|
|
65
|
+
- `errors` is a list of failure schemas, defaulting to none. Each schema retains
|
|
48
66
|
its own `httpApiStatus` annotation.
|
|
67
|
+
- A group's `errors` are added to every one of its actions, so a shared set, such as
|
|
68
|
+
authorization failures, is declared once. Its `schemaError` policy decides how both
|
|
69
|
+
transports answer failed decoding or encoding; see
|
|
70
|
+
[schema-error policies](docs/behavior.md#schema-error-policies).
|
|
49
71
|
- Actions default to both transports; use `http: false` or `mcp: false` to opt out.
|
|
72
|
+
An action exposed on neither is rejected.
|
|
50
73
|
- MCP input must have an object-root JSON Schema; declared errors must encode to
|
|
51
74
|
objects. The MCP adapter checks this at Layer construction. HTTP allows scalars.
|
|
52
75
|
- `mcp.name` overrides the tool name. `destructive` defaults to `!readOnly`.
|
|
53
76
|
- Schemas must be service-free. Handlers may require services.
|
|
77
|
+
- The group name is the OpenAPI tag and operation-ID prefix (`greetings.greet`).
|
|
54
78
|
|
|
55
79
|
## HTTP client
|
|
56
80
|
|
|
@@ -59,37 +83,84 @@ Save the quickstart above as `quickstart.ts`. Once its routes are served:
|
|
|
59
83
|
```ts
|
|
60
84
|
import { Effect } from "effect";
|
|
61
85
|
import { FetchHttpClient } from "effect/unstable/http";
|
|
62
|
-
import {
|
|
63
|
-
import { Actions } from "./quickstart.js";
|
|
86
|
+
import { Http } from "./quickstart.js";
|
|
64
87
|
|
|
65
88
|
export const greeting = Effect.gen(function* () {
|
|
66
|
-
const client = yield*
|
|
67
|
-
|
|
68
|
-
baseUrl: "http://127.0.0.1:3000",
|
|
69
|
-
});
|
|
89
|
+
const client = yield* Http.client({ baseUrl: "http://127.0.0.1:3000" });
|
|
90
|
+
|
|
70
91
|
return yield* client.greet({ name: "Ada" });
|
|
71
92
|
}).pipe(Effect.provide(FetchHttpClient.layer));
|
|
72
93
|
```
|
|
73
94
|
|
|
74
95
|
Run `greeting` with `Effect.runPromise`. Methods take decoded inputs and return
|
|
75
|
-
decoded results; MCP-only actions are excluded.
|
|
76
|
-
|
|
96
|
+
decoded results; MCP-only actions are excluded. `ActionHttp.make` binds the mount path the
|
|
97
|
+
server and its clients must agree on, so `client` accepts only connection options. Authentication headers can be added with
|
|
98
|
+
`transformClient`. See [client details](docs/behavior.md#http-client-details)
|
|
77
99
|
for error types, optional inputs, and native grouped clients.
|
|
78
100
|
|
|
101
|
+
## Multiple groups
|
|
102
|
+
|
|
103
|
+
An application can split its contracts and handlers into groups without splitting its
|
|
104
|
+
endpoints. Configuration comes first, then the groups or implementations:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const Http = ActionHttp.make({ apiPath: "/api/actions" }, PublicActions, UserActions);
|
|
108
|
+
|
|
109
|
+
const routes = Layer.mergeAll(
|
|
110
|
+
Http.layer(PublicApp),
|
|
111
|
+
Http.layer(UserApp).pipe(Layer.provide(authentication.layer)),
|
|
112
|
+
ActionMcp.layer({ name: "my-app", version: "1.0.0", path: "/mcp" }, UserApp, AuditApp),
|
|
113
|
+
);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **One layer per group.** `Http.layer(app)` registers the routes of one group, and router
|
|
117
|
+
middleware provided to a layer applies to that layer alone: above, only the user group
|
|
118
|
+
requires authentication. A group that is never mounted has no routes.
|
|
119
|
+
- **Each adapter reads only what it serves.** A group without HTTP actions registers nothing
|
|
120
|
+
and is not built by `Http.layer`, nor one without tools by `ActionMcp.layer`.
|
|
121
|
+
- **Names.** Routes and client methods are flat (`POST /api/actions/<action>`,
|
|
122
|
+
`client.<action>()`), so group names and HTTP action names must be unique within one
|
|
123
|
+
`ActionHttp.make`, and tool names within one `ActionMcp.layer`. Duplicates throw at
|
|
124
|
+
construction; neither adapter looks at the other's names.
|
|
125
|
+
- **MCP middleware is per endpoint.** An MCP endpoint is one route, so middleware provided
|
|
126
|
+
to `ActionMcp.layer`, authentication included, covers all of its tools. Handlers can still
|
|
127
|
+
authorize each tool differently. Only tools that need different middleware, such as none
|
|
128
|
+
at all, need their own endpoint: one `ActionMcp.layer` per `path`.
|
|
129
|
+
|
|
130
|
+
Each group is its own native `HttpApiGroup`, so a host can also combine separately made
|
|
131
|
+
`Http.api` values with `HttpApi.addHttpApi`. See [examples/app.ts](examples/app.ts).
|
|
132
|
+
|
|
133
|
+
## Serving the document
|
|
134
|
+
|
|
135
|
+
`Http.api` is a native `HttpApi`, carrying every route, description, declared error and
|
|
136
|
+
policy error. Documents and documentation UIs are therefore Effect's own:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
140
|
+
import { HttpApiScalar, HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
|
|
141
|
+
|
|
142
|
+
const documentation = Layer.mergeAll(
|
|
143
|
+
HttpRouter.add("GET", "/openapi.json", HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api))),
|
|
144
|
+
HttpApiSwagger.layer(Http.api, { path: "/docs" }),
|
|
145
|
+
HttpApiScalar.layer(Http.api, { path: "/reference" }),
|
|
146
|
+
);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Each is an ordinary route layer, so it takes whatever middleware it is provided. The same
|
|
150
|
+
value gives Effect's grouped client: `HttpApiClient.make(Http.api)`.
|
|
151
|
+
|
|
79
152
|
## Adapter options
|
|
80
153
|
|
|
81
|
-
| API
|
|
82
|
-
|
|
|
83
|
-
| `
|
|
84
|
-
| `ActionHttp.
|
|
85
|
-
| `
|
|
86
|
-
| `
|
|
154
|
+
| API | Options |
|
|
155
|
+
| ------------------------------------- | ------------------------------------------------------------------------ |
|
|
156
|
+
| `ActionGroup.make(options, …actions)` | `name`, `errors`, `schemaError` |
|
|
157
|
+
| `ActionHttp.make(options, …groups)` | `apiPath` |
|
|
158
|
+
| `Http.layer(app)` | None |
|
|
159
|
+
| `Http.client(options?)` | `baseUrl`, `transformClient`, `transformResponse` |
|
|
160
|
+
| `ActionMcp.layer(options, …apps)` | `name`, `version`, `path`, `protocols`, `allowedOrigins`, `instructions` |
|
|
87
161
|
|
|
88
|
-
`apiPath
|
|
89
|
-
|
|
90
|
-
for its `layer`, `api`, `openapi`, and `client` methods. Configured clients accept
|
|
91
|
-
only connection options. Set `openapiPath: false` when the host serves a combined
|
|
92
|
-
document.
|
|
162
|
+
`apiPath` and MCP `path` have no defaults. `Http` has three members: `api`, `layer` and
|
|
163
|
+
`client`.
|
|
93
164
|
|
|
94
165
|
See [adapter behavior](docs/behavior.md) for shared schema-error policies,
|
|
95
166
|
dependency lifetimes, wire formats, and MCP protocol support.
|
|
@@ -118,11 +189,13 @@ vp run example
|
|
|
118
189
|
```
|
|
119
190
|
|
|
120
191
|
`vp run dev` watches the library build. `vp check` also verifies the compile-time
|
|
121
|
-
assertions in `tests/types.ts`. Tests cover both transports, official MCP clients,
|
|
192
|
+
assertions in `tests/types.spec.ts`. Tests cover both transports, official MCP clients,
|
|
122
193
|
context isolation, schema-error policies, and cancellation.
|
|
123
194
|
|
|
124
|
-
`vp run build` emits ESM and declarations into `dist/`.
|
|
125
|
-
|
|
195
|
+
`vp run build` emits ESM and declarations into `dist/`. The entry points are one subpath per
|
|
196
|
+
module: `/Action`, `/ActionGroup`, `/ActionHttp`, `/ActionMcp`, `/Authentication`, `/Testing`,
|
|
197
|
+
and `/TestingClient`. There is no package root, so a contracts-only or browser bundle never
|
|
198
|
+
loads the MCP server or the optional client peer.
|
|
126
199
|
`vp run test:package` builds and checks a tarball in an isolated consumer using the pinned
|
|
127
200
|
Effect snapshot; it does not establish compatibility with the published peer.
|
|
128
201
|
|
|
@@ -142,7 +215,7 @@ Use `.combine(...)` with middleware that provides them. Acquired resources stay
|
|
|
142
215
|
until the request scope closes. Downstream action errors are handled by their transport,
|
|
143
216
|
not serialized as authentication failures.
|
|
144
217
|
|
|
145
|
-
`
|
|
218
|
+
`Authentication.protectedResource({ resource, authorizationServers, scopesSupported })`
|
|
146
219
|
returns `{ layer, metadataUrl, challenge }`. Mount its public `layer` separately from
|
|
147
220
|
protected routes. It publishes [RFC 9728 metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
|
|
148
221
|
at `/.well-known/oauth-protected-resource` followed by the resource path and query. Discovery
|
|
@@ -155,16 +228,48 @@ The helper supports HTTPS URLs and loopback HTTP for development, with no creden
|
|
|
155
228
|
or fragment. Resource identifiers may include a query; authorization-server issuers may not.
|
|
156
229
|
Your application owns login, consent, and token verification.
|
|
157
230
|
|
|
158
|
-
|
|
231
|
+
```ts
|
|
232
|
+
const discovery = Authentication.protectedResource({
|
|
233
|
+
resource: "https://api.example.com/mcp",
|
|
234
|
+
authorizationServers: ["https://auth.example.com"],
|
|
235
|
+
});
|
|
159
236
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
237
|
+
const authentication = Authentication.middleware(CurrentActor, {
|
|
238
|
+
authenticate,
|
|
239
|
+
errors: [Unauthenticated],
|
|
240
|
+
headers: () => ({ "www-authenticate": discovery.challenge({ error: "invalid_token" }) }),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const routes = Layer.mergeAll(
|
|
244
|
+
discovery.layer,
|
|
245
|
+
protectedRoutes.pipe(Layer.provide(authentication.layer)),
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Testing
|
|
250
|
+
|
|
251
|
+
Import from `@gjermundgaraba/effect-actions/Testing`; it requires no MCP client dependency.
|
|
252
|
+
`withMcpClient` lives in `@gjermundgaraba/effect-actions/TestingClient` and needs the optional
|
|
253
|
+
peer `@modelcontextprotocol/client@^2.0.0`. Only that entry point loads the peer.
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
const web = HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)));
|
|
257
|
+
|
|
258
|
+
const greeting = Effect.gen(function* () {
|
|
259
|
+
const client = yield* httpClient(Http, web.handler);
|
|
260
|
+
|
|
261
|
+
return yield* client.greet({ name: "Ada" });
|
|
262
|
+
});
|
|
263
|
+
```
|
|
163
264
|
|
|
164
|
-
- `
|
|
165
|
-
`
|
|
265
|
+
- `httpClient(Http, handler, options?)` is the typed HTTP client calling a web handler in
|
|
266
|
+
memory. `options` are the client's connection options; `baseUrl` defaults to `http://localhost`.
|
|
267
|
+
- `mcpRequest({ url, method, params?, headers? })` builds a stateless 2026-07-28 request.
|
|
268
|
+
`params` takes whatever `JSON.stringify` accepts, `undefined` fields included, so tests can
|
|
269
|
+
send malformed arguments.
|
|
270
|
+
Caller `params._meta` fields override the default client capabilities
|
|
166
271
|
and information; application metadata is preserved. The protocol version stays pinned to
|
|
167
272
|
2026-07-28 in both the request header and metadata. Metadata is merged shallowly.
|
|
168
|
-
- `withMcpClient(fetch,
|
|
169
|
-
connects the official client and closes it in a `finally` block.
|
|
273
|
+
- `withMcpClient({ fetch, path, mode?, baseUrl?, headers? }, async client => ...)`
|
|
274
|
+
connects the official client and closes it in a `finally` block.
|
|
170
275
|
`mode` defaults to `"modern"` (2026-07-28); use `"legacy"` to exercise session negotiation.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
declare namespace Action_d_exports {
|
|
3
|
+
export { Action, Any, Codec, Handler, McpOptions, Options, SchemaErrorPolicy, SchemaFailure, make };
|
|
4
|
+
}
|
|
5
|
+
type Codec = Schema.Codec<unknown, unknown, never, never>;
|
|
6
|
+
/** Failed request decoding or successful-result encoding, independent of transport terminology. */
|
|
7
|
+
interface SchemaFailure {
|
|
8
|
+
readonly phase: "input" | "output";
|
|
9
|
+
/** May contain sensitive values. Do not reflect it in public error messages. */
|
|
10
|
+
readonly cause: Schema.SchemaError;
|
|
11
|
+
}
|
|
12
|
+
/** Pure application-owned mapping, set on a group and applied by both adapters. */
|
|
13
|
+
interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
|
|
14
|
+
readonly errors: Errors;
|
|
15
|
+
readonly map: (failure: SchemaFailure) => NoInfer<Errors[number]["Type"]>;
|
|
16
|
+
}
|
|
17
|
+
interface McpOptions {
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
readonly readOnly?: boolean;
|
|
20
|
+
/** Defaults to `!readOnly`; the MCP spec only defines it for non-read-only tools. */
|
|
21
|
+
readonly destructive?: boolean;
|
|
22
|
+
}
|
|
23
|
+
interface Options<Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean> {
|
|
24
|
+
readonly description: string;
|
|
25
|
+
/** Omit for an action without arguments. */
|
|
26
|
+
readonly input?: Input;
|
|
27
|
+
readonly success: Output;
|
|
28
|
+
/** One schema per declared failure; each keeps its own HTTP status annotation. Defaults to none. */
|
|
29
|
+
readonly errors?: Errors;
|
|
30
|
+
readonly http?: Http;
|
|
31
|
+
readonly mcp?: false | McpOptions;
|
|
32
|
+
}
|
|
33
|
+
/** A pure contract: schemas and transport metadata. Implementations are bound by `ActionGroup.implement`. */
|
|
34
|
+
interface Action<Name extends string, Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean> {
|
|
35
|
+
readonly name: Name;
|
|
36
|
+
readonly description: string;
|
|
37
|
+
readonly input: Input;
|
|
38
|
+
readonly success: Output;
|
|
39
|
+
readonly errors: Errors;
|
|
40
|
+
readonly http: Http;
|
|
41
|
+
readonly mcp: false | {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly readOnly: boolean;
|
|
44
|
+
readonly destructive: boolean;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
type Any = Action<string, Codec, Codec, ReadonlyArray<Codec>>;
|
|
48
|
+
/** Receives decoded input; may fail only with the declared errors. */
|
|
49
|
+
type Handler<A extends Any, R = never> = (input: A["input"]["Type"]) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
|
|
50
|
+
/** An empty object schema that also produces the object root MCP requires. */
|
|
51
|
+
declare const NoInput: Schema.$Record<Schema.String, Schema.Never>;
|
|
52
|
+
declare function make<const Name extends string, Input extends Codec = typeof NoInput, Output extends Codec = never, const Errors extends ReadonlyArray<Codec> = [], const Http extends boolean = true>(name: Name, options: Options<Input, Output, Errors, Http>): Action<Name, Input, Output, Errors, Http>;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { Handler as a, SchemaErrorPolicy as c, __exportAll as d, Codec as i, SchemaFailure as l, Action_d_exports as n, McpOptions as o, Any as r, Options as s, Action as t, make as u };
|
package/dist/Action.d.mts
CHANGED
|
@@ -1,54 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export { Action, Any, Codec, Handler, McpOptions, NoInput, Options, SchemaErrorPolicy, SchemaFailure, make };
|
|
4
|
-
}
|
|
5
|
-
export type Codec = Schema.Codec<unknown, unknown, never, never>;
|
|
6
|
-
/** Failed request decoding or successful-result encoding, independent of transport terminology. */
|
|
7
|
-
export interface SchemaFailure {
|
|
8
|
-
readonly phase: "input" | "output";
|
|
9
|
-
/** May contain sensitive values. Do not reflect it in public error messages. */
|
|
10
|
-
readonly cause: Schema.SchemaError;
|
|
11
|
-
}
|
|
12
|
-
/** Pure application-owned mapping, shared by the HTTP and MCP adapters. */
|
|
13
|
-
export interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
|
|
14
|
-
readonly errors: Errors;
|
|
15
|
-
readonly map: (failure: SchemaFailure) => NoInfer<Errors[number]["Type"]>;
|
|
16
|
-
}
|
|
17
|
-
export interface McpOptions {
|
|
18
|
-
readonly name?: string;
|
|
19
|
-
readonly readOnly?: boolean;
|
|
20
|
-
/** Defaults to `!readOnly`; the MCP spec only defines it for non-read-only tools. */
|
|
21
|
-
readonly destructive?: boolean;
|
|
22
|
-
}
|
|
23
|
-
export interface Options<Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean> {
|
|
24
|
-
readonly description: string;
|
|
25
|
-
/** Defaults to `NoInput`. */
|
|
26
|
-
readonly input?: Input;
|
|
27
|
-
readonly success: Output;
|
|
28
|
-
/** One schema per declared failure; each keeps its own HTTP status annotation. Defaults to none. */
|
|
29
|
-
readonly error?: Errors;
|
|
30
|
-
readonly http?: Http;
|
|
31
|
-
readonly mcp?: false | McpOptions;
|
|
32
|
-
}
|
|
33
|
-
/** A pure contract: schemas and transport metadata. Implementations are bound by `ActionGroup.implement`. */
|
|
34
|
-
export interface Action<Name extends string, Input extends Codec, Output extends Codec, Errors extends ReadonlyArray<Codec>, Http extends boolean = boolean> {
|
|
35
|
-
readonly name: Name;
|
|
36
|
-
readonly description: string;
|
|
37
|
-
readonly input: Input;
|
|
38
|
-
readonly success: Output;
|
|
39
|
-
readonly errors: Errors;
|
|
40
|
-
readonly http: Http;
|
|
41
|
-
readonly mcp: false | {
|
|
42
|
-
readonly name: string;
|
|
43
|
-
readonly readOnly: boolean;
|
|
44
|
-
readonly destructive: boolean;
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
export type Any = Action<string, Codec, Codec, ReadonlyArray<Codec>>;
|
|
48
|
-
/** Receives decoded input; may fail only with the declared errors. */
|
|
49
|
-
export type Handler<A extends Any, R = never> = (input: A["input"]["Type"]) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
|
|
50
|
-
/** An empty object schema that also produces the object root MCP requires. */
|
|
51
|
-
export declare const NoInput: Schema.$Record<Schema.String, Schema.Never>;
|
|
52
|
-
export declare function make<const Name extends string, Input extends Codec = typeof NoInput, Output extends Codec = never, const Errors extends ReadonlyArray<Codec> = [], const Http extends boolean = true>(name: Name, options: Options<Input, Output, Errors, Http>): Action<Name, Input, Output, Errors, Http>;
|
|
53
|
-
//#endregion
|
|
54
|
-
export { Action_d_exports as t };
|
|
1
|
+
import { a as Handler, c as SchemaErrorPolicy, i as Codec, l as SchemaFailure, o as McpOptions, r as Any, s as Options, t as Action, u as make } from "./Action-CXsfTEhx.mjs";
|
|
2
|
+
export { Action, Any, Codec, Handler, McpOptions, Options, SchemaErrorPolicy, SchemaFailure, make };
|
package/dist/Action.mjs
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
1
|
import { Schema } from "effect";
|
|
3
2
|
//#region src/Action.ts
|
|
4
|
-
var Action_exports = /* @__PURE__ */ __exportAll({
|
|
5
|
-
NoInput: () => NoInput,
|
|
6
|
-
make: () => make
|
|
7
|
-
});
|
|
8
3
|
/** An empty object schema that also produces the object root MCP requires. */
|
|
9
4
|
const NoInput = Schema.Record(Schema.String, Schema.Never);
|
|
10
5
|
function make(name, options) {
|
|
11
6
|
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) throw new Error(`Invalid action name: ${name}`);
|
|
7
|
+
if (options.http === false && options.mcp === false) throw new Error(`Action ${name} is exposed on no transport`);
|
|
12
8
|
const readOnly = options.mcp === false ? false : options.mcp?.readOnly ?? false;
|
|
13
9
|
const mcp = options.mcp === false ? false : {
|
|
14
10
|
name: options.mcp?.name ?? name,
|
|
@@ -21,10 +17,10 @@ function make(name, options) {
|
|
|
21
17
|
description: options.description,
|
|
22
18
|
input: options.input ?? NoInput,
|
|
23
19
|
success: options.success,
|
|
24
|
-
errors: options.
|
|
20
|
+
errors: options.errors ?? [],
|
|
25
21
|
http: options.http !== false,
|
|
26
22
|
mcp
|
|
27
23
|
};
|
|
28
24
|
}
|
|
29
25
|
//#endregion
|
|
30
|
-
export {
|
|
26
|
+
export { make };
|
package/dist/ActionGroup.d.mts
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { a as Handler, c as SchemaErrorPolicy, i as Codec, n as Action_d_exports, r as Any$1, t as Action } from "./Action-CXsfTEhx.mjs";
|
|
2
|
+
import { i as Implementation, o as Actions } from "./implementation-DTqp_0DW.mjs";
|
|
3
3
|
import { Effect, Scope } from "effect";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
type HandlersFrom<Actions extends ReadonlyArray<Any>> = { readonly [A in Actions[number] as A["name"]]: Handler<A, any>; };
|
|
4
|
+
//#region src/ActionGroup.d.ts
|
|
5
|
+
type HandlersFrom<Actions extends ReadonlyArray<Any$1>> = { readonly [A in Actions[number] as A["name"]]: Handler<A, any>; };
|
|
8
6
|
type HandlersContext<H> = { [K in keyof H]: H[K] extends ((input: never) => Effect.Effect<infer _A, infer _E, infer R>) ? R : never; }[keyof H];
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
/** Group-level errors join each action's own, so a shared set is declared once. */
|
|
8
|
+
type WithErrors<Actions extends ReadonlyArray<Any$1>, Errors extends ReadonlyArray<Codec>> = { readonly [K in keyof Actions]: Actions[K] extends Action<infer Name, infer Input, infer Output, infer Own, infer Http> ? Action<Name, Input, Output, readonly [...Own, ...Errors], Http> : never; };
|
|
9
|
+
export interface Options<Name extends string, Errors extends ReadonlyArray<Codec>, PolicyErrors extends ReadonlyArray<Codec>> {
|
|
10
|
+
/** The `HttpApiGroup` identifier, and so the OpenAPI tag and operation-ID prefix. */
|
|
11
|
+
readonly name: Name;
|
|
12
|
+
/** Failures every action of the group may have, added to each action's own. */
|
|
13
|
+
readonly errors?: Errors;
|
|
14
|
+
/** How both transports answer failed decoding or encoding of these actions. */
|
|
15
|
+
readonly schemaError?: SchemaErrorPolicy<PolicyErrors>;
|
|
16
|
+
}
|
|
17
|
+
export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>, PolicyErrors extends ReadonlyArray<Codec> = ReadonlyArray<Codec>> extends Actions<Name, Actions$1, PolicyErrors> {
|
|
11
18
|
/**
|
|
12
19
|
* Bind every handler at once. Pass an Effect to resolve build-time services
|
|
13
20
|
* once (`const users = yield* Users`); services yielded inside a handler are
|
|
14
21
|
* request-scoped instead. Scoped acquisition lasts for the adapter runtime.
|
|
15
22
|
*/
|
|
16
|
-
readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never>(build: H | Effect.Effect<H, EX, RX>) => Implementation<Actions, HandlersContext<H>, EX
|
|
23
|
+
readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never>(build: H | Effect.Effect<H, EX, RX>) => Implementation<Group<Name, Actions$1, PolicyErrors>, HandlersContext<H>, NoInfer<EX>, NoInfer<Exclude<RX, Scope.Scope>>>;
|
|
17
24
|
}
|
|
25
|
+
export type Any = Group<string, ReadonlyArray<Any$1>>;
|
|
18
26
|
/** Duplicate action and MCP names fail at definition time. */
|
|
19
|
-
export declare
|
|
27
|
+
export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [], const PolicyErrors extends ReadonlyArray<Codec> = []>(options: Options<Name, Errors, PolicyErrors>, ...actions: Actions): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
|
|
20
28
|
//#endregion
|
|
21
|
-
export {
|
|
29
|
+
export type { Implementation };
|
package/dist/ActionGroup.mjs
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
|
-
import { t as
|
|
2
|
-
import { t as Implementation } from "./implementation-BkLqMWtv.mjs";
|
|
1
|
+
import { n as assertDistinct, t as Implementation } from "./implementation-4Q2BESw5.mjs";
|
|
3
2
|
import { Effect } from "effect";
|
|
4
3
|
//#region src/ActionGroup.ts
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return {
|
|
4
|
+
function make(options, ...declared) {
|
|
5
|
+
const { name } = options;
|
|
6
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) throw new Error(`Invalid action group name: ${name}`);
|
|
7
|
+
const actions = declared.map((action) => ({
|
|
8
|
+
...action,
|
|
9
|
+
errors: [...action.errors, ...options.errors ?? []]
|
|
10
|
+
}));
|
|
11
|
+
assertDistinct("action", actions.map((action) => action.name));
|
|
12
|
+
assertDistinct("MCP tool", actions.flatMap((action) => action.mcp === false ? [] : [action.mcp.name]));
|
|
13
|
+
const group = {
|
|
14
|
+
name,
|
|
19
15
|
actions,
|
|
16
|
+
schemaError: options.schemaError,
|
|
20
17
|
implement: (build) => {
|
|
21
18
|
const built = Effect.isEffect(build) ? build : Effect.succeed(build);
|
|
22
|
-
return Implementation.make(
|
|
19
|
+
return Implementation.make(group, built);
|
|
23
20
|
}
|
|
24
21
|
};
|
|
25
|
-
|
|
22
|
+
return group;
|
|
23
|
+
}
|
|
26
24
|
//#endregion
|
|
27
|
-
export { make
|
|
25
|
+
export { make };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { d as __exportAll, i as Codec, n as Action_d_exports, r as Any } from "./Action-CXsfTEhx.mjs";
|
|
2
|
+
import { a as RequestContext, n as BuildContext, o as Actions, r as BuildError, s as PolicyError, t as AnyImplementation } from "./implementation-DTqp_0DW.mjs";
|
|
3
|
+
import { Effect, Layer, Schema } from "effect";
|
|
4
|
+
import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
|
|
5
|
+
import { Etag, HttpClient, HttpPlatform, HttpRouter } from "effect/unstable/http";
|
|
6
|
+
import { FileSystem } from "effect/FileSystem";
|
|
7
|
+
import { Path } from "effect/Path";
|
|
8
|
+
declare namespace ActionHttp_d_exports {
|
|
9
|
+
export { Api, Client, ClientOptions, Http, Options, make };
|
|
10
|
+
}
|
|
11
|
+
/** Contract-level configuration: servers and clients must agree on it. */
|
|
12
|
+
export interface Options {
|
|
13
|
+
readonly apiPath: `/${string}`;
|
|
14
|
+
}
|
|
15
|
+
/** Distribute over the tuple so each name retains its own codecs. */
|
|
16
|
+
type Endpoint<A extends Any, E extends Codec> = A extends {
|
|
17
|
+
readonly http: false;
|
|
18
|
+
} ? never : A extends Any ? HttpApiEndpoint.HttpApiEndpoint<A["name"], "POST", `/${string}`, never, never, Schema.toCodecJson<A["input"]>, never, Schema.toCodecJson<A["success"]>, Schema.toCodecJson<A["errors"][number] | E>> : never;
|
|
19
|
+
/** One native group per action group; groups without HTTP actions are omitted. */
|
|
20
|
+
type ApiGroup<G extends Actions> = G extends Actions ? [Endpoint<G["actions"][number], never>] extends [never] ? never : HttpApiGroup.HttpApiGroup<G["name"], Endpoint<G["actions"][number], PolicyError<G>>> : never;
|
|
21
|
+
export type Api<G extends Actions> = HttpApi.HttpApi<"actions", ApiGroup<G>>;
|
|
22
|
+
type Served<G extends Actions> = G extends Actions ? G["actions"][number] extends (infer Item) ? Item extends Any ? {
|
|
23
|
+
readonly action: Item;
|
|
24
|
+
readonly policyError: PolicyError<G>;
|
|
25
|
+
} : never : never : never;
|
|
26
|
+
/** Direct decoded-input methods of every group, excluding MCP-only actions. */
|
|
27
|
+
export type Client<G extends Actions> = { readonly [Pair in Served<G> as Pair["action"]["http"] extends false ? never : Pair["action"]["name"]]: (...args: {} extends Pair["action"]["input"]["Type"] ? [input?: Pair["action"]["input"]["Type"]] : [input: Pair["action"]["input"]["Type"]]) => HttpApiClient.Client.MethodReturn<Endpoint<Pair["action"], Pair["policyError"]>, never, never, "decoded-only">; };
|
|
28
|
+
/** Native connection options: `baseUrl`, `transformClient`, `transformResponse`. */
|
|
29
|
+
export type ClientOptions = NonNullable<Parameters<typeof HttpApiClient.make>[1]>;
|
|
30
|
+
export interface Http<G extends Actions> {
|
|
31
|
+
/**
|
|
32
|
+
* The native `HttpApi` for every HTTP-enabled action: `POST <apiPath>/<name>`.
|
|
33
|
+
* Documents, documentation UIs and native clients are Effect's own, from this value.
|
|
34
|
+
*/
|
|
35
|
+
readonly api: Api<G>;
|
|
36
|
+
/**
|
|
37
|
+
* The routes of one group. Merge one per group; router middleware provided
|
|
38
|
+
* to a layer applies to that group only. Handler requirements are
|
|
39
|
+
* request-level requirements, exactly as native `HttpApiBuilder` handlers'
|
|
40
|
+
* are. A group without HTTP actions registers nothing and is not acquired.
|
|
41
|
+
*/
|
|
42
|
+
readonly layer: <App extends AnyImplementation<G>>(app: App) => Layer.Layer<never, BuildError<App>, BuildContext<App> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", RequestContext<App>> | Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path>;
|
|
43
|
+
/** Direct action methods backed by the native HTTP client and its codecs. */
|
|
44
|
+
readonly client: (options?: ClientOptions) => Effect.Effect<Client<G>, never, HttpClient.HttpClient>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Bind the contract-level configuration once. The native API is built here and
|
|
48
|
+
* shared by the routes and clients, so they cannot disagree.
|
|
49
|
+
*/
|
|
50
|
+
export declare function make<const G extends ReadonlyArray<Actions>>(options: Options, ...groups: G): Http<G[number]>;
|
|
51
|
+
//#endregion
|
|
52
|
+
export { ActionHttp_d_exports as t };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { n as assertDistinct, r as served, t as Implementation } from "./implementation-4Q2BESw5.mjs";
|
|
2
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { HttpApi, HttpApiBuilder, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi";
|
|
4
|
+
//#region src/ActionHttp.ts
|
|
5
|
+
const endpoint = (apiPath, action, errors) => HttpApiEndpoint.post(action.name, `${apiPath}/${action.name}`, {
|
|
6
|
+
payload: action.input,
|
|
7
|
+
success: action.success,
|
|
8
|
+
error: [...action.errors, ...errors]
|
|
9
|
+
}).annotate(OpenApi.Description, action.description);
|
|
10
|
+
function erasedApi(views, options) {
|
|
11
|
+
const [first, ...rest] = views.flatMap(({ group, actions }) => {
|
|
12
|
+
const [head, ...tail] = actions.map((action) => endpoint(options.apiPath, action, group.schemaError?.errors ?? []));
|
|
13
|
+
return head === void 0 ? [] : [HttpApiGroup.make(group.name).add(head, ...tail)];
|
|
14
|
+
});
|
|
15
|
+
const empty = HttpApi.make("actions");
|
|
16
|
+
return first === void 0 ? empty : empty.add(first, ...rest);
|
|
17
|
+
}
|
|
18
|
+
const schemaErrorMiddleware = (policy) => {
|
|
19
|
+
class SchemaErrors extends HttpApiMiddleware.Service()("effect-actions/http/SchemaErrors", { error: policy?.errors ?? [] }) {}
|
|
20
|
+
return {
|
|
21
|
+
SchemaErrors,
|
|
22
|
+
layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(policy === void 0 ? failure : policy.map({
|
|
23
|
+
phase: failure.kind === "Body" || failure.kind === "ResponseHeaders" ? "output" : "input",
|
|
24
|
+
cause: failure.cause
|
|
25
|
+
})))
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** One group registers its own routes, so middleware provided to this layer is its alone. */
|
|
29
|
+
const erasedLayer = (binding, app) => {
|
|
30
|
+
if (!binding.groups.includes(app.group)) throw new Error(`Implementation of group "${app.group.name}" is not served by this adapter`);
|
|
31
|
+
const view = binding.views.find(({ group }) => group === app.group);
|
|
32
|
+
if (view === void 0) return Layer.empty;
|
|
33
|
+
const schemaErrors = schemaErrorMiddleware(view.group.schemaError);
|
|
34
|
+
const httpApi = erasedApi([view], binding.options).middleware(schemaErrors.SchemaErrors);
|
|
35
|
+
return Implementation.register([{
|
|
36
|
+
app,
|
|
37
|
+
actions: view.actions
|
|
38
|
+
}], (bound) => {
|
|
39
|
+
const handlers = bound.map((implementation) => HttpApiBuilder.group(httpApi, implementation.group.name, (builder) => builder.handleAll(Object.fromEntries(implementation.actions.map((action) => {
|
|
40
|
+
const handle = implementation.handle(action);
|
|
41
|
+
return [action.name, (request) => handle(request.payload)];
|
|
42
|
+
})))));
|
|
43
|
+
const isolated = Layer.fromBuildMemo((memoMap, scope) => Layer.buildWithMemoMap(Layer.mergeAll(Layer.empty, ...handlers).pipe(Layer.provide(schemaErrors.layer)), memoMap, scope).pipe(Effect.setContext(Context.empty())));
|
|
44
|
+
return HttpApiBuilder.layer(httpApi).pipe(Layer.provide(isolated));
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const erasedClient = (api, views, connection) => Effect.map(HttpApiClient.make(api, connection), (native) => {
|
|
48
|
+
const acceptsUndefined = new Set(views.flatMap(({ actions }) => actions.flatMap((action) => Schema.is(action.input)(void 0) ? [action.name] : [])));
|
|
49
|
+
return Object.fromEntries(Object.values(native).flatMap((methods) => Object.entries(methods).map(([name, method]) => {
|
|
50
|
+
if (name === "then") throw new Error("Action \"then\" requires the native grouped HttpApiClient");
|
|
51
|
+
return [name, (input) => method({
|
|
52
|
+
payload: input === void 0 && !acceptsUndefined.has(name) ? {} : input,
|
|
53
|
+
responseMode: "decoded-only"
|
|
54
|
+
})];
|
|
55
|
+
})));
|
|
56
|
+
});
|
|
57
|
+
function make(options, ...groups) {
|
|
58
|
+
const views = served(groups, (action) => action.http);
|
|
59
|
+
assertDistinct("action group", groups.map((group) => group.name));
|
|
60
|
+
assertDistinct("action", views.flatMap(({ actions }) => actions.map((action) => action.name)));
|
|
61
|
+
const api = erasedApi(views, options);
|
|
62
|
+
const binding = {
|
|
63
|
+
groups,
|
|
64
|
+
views,
|
|
65
|
+
options
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
api,
|
|
69
|
+
layer: (app) => erasedLayer(binding, app),
|
|
70
|
+
client: (connection) => erasedClient(api, views, connection)
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { make };
|