@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 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
- **Experimental release candidate.** The repository currently uses Effect snapshot
6
- [`4e4a3a6`](https://pkg.pr.new/Effect-TS/effect/effect@4e4a3a6). The MCP adapter
7
- requires APIs missing from published `4.0.0-rc.115`, despite that being the current
8
- peer declaration. Until these versions are aligned, use the pinned snapshot;
9
- the declared peer version alone is not sufficient.
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 { Action, ActionGroup, ActionHttp, ActionMcp } from "@gjermundgaraba/effect-actions";
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
- ActionHttp.layer(app, {
31
- apiPath: "/api/actions",
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
- `GET /openapi.json`, and an MCP endpoint at `/mcp`. The HTTP response is
40
- `"Hello, Ada!"`; MCP returns `structuredContent: { value: "Hello, Ada!" }`.
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` defaults to `Action.NoInput` for actions without arguments.
47
- - `error` is a list of failure schemas, defaulting to none. Each schema retains
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 { ActionHttp } from "@gjermundgaraba/effect-actions";
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* ActionHttp.client(Actions, {
67
- apiPath: "/api/actions",
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. Authentication
76
- headers can be added with `transformClient`. See [client details](docs/behavior.md#http-client-details)
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 | Options |
82
- | ---------------------------- | --------------------------------------------------------------------------------------- |
83
- | `ActionHttp.layer` | `apiPath`, `openapiPath`, `schemaError` |
84
- | `ActionHttp.api` / `openapi` | `apiPath`, `schemaError` |
85
- | `ActionHttp.client` | `apiPath`, `schemaError`, `baseUrl`, `transformClient`, `transformResponse` |
86
- | `ActionMcp.layer` | `name`, `version`, `path`, `protocols`, `allowedOrigins`, `instructions`, `schemaError` |
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`, `openapiPath` (on `layer` / `configure`), and MCP `path` are required —
89
- the library has no defaults. `ActionHttp.configure(options)` binds HTTP configuration
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/`. Public entry points are the
125
- package root, `/Action`, `/ActionGroup`, `/http`, `/mcp`, `/authentication`, `/testing`, and `/testing/client`.
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
- `ActionMcp.protectedResource({ resource, authorizationServers, scopesSupported })`
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
- ## MCP testing
231
+ ```ts
232
+ const discovery = Authentication.protectedResource({
233
+ resource: "https://api.example.com/mcp",
234
+ authorizationServers: ["https://auth.example.com"],
235
+ });
159
236
 
160
- Import `mcpRequest` from `@gjermundgaraba/effect-actions/testing`; it requires no MCP client dependency.
161
- Import `withMcpClient` from `@gjermundgaraba/effect-actions/testing/client` and install
162
- its optional peer `@modelcontextprotocol/client@^2.0.0`. Only the client entry point loads this peer.
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
- - `mcpRequest(method, params?, { url, headers? })` builds a stateless 2026-07-28 request.
165
- `url` is required. Caller `params._meta` fields override the default client capabilities
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, async client => ..., { path, mode?, baseUrl?, headers? })`
169
- connects the official client and closes it in a `finally` block. `path` is required.
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 { Effect, Schema } from "effect";
2
- declare namespace Action_d_exports {
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.error ?? [],
20
+ errors: options.errors ?? [],
25
21
  http: options.http !== false,
26
22
  mcp
27
23
  };
28
24
  }
29
25
  //#endregion
30
- export { NoInput, make, Action_exports as t };
26
+ export { make };
@@ -1,21 +1,29 @@
1
- import { Any, Handler, t as Action_d_exports } from "./Action.mjs";
2
- import { t as Implementation } from "./implementation-D75QAcD3.mjs";
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
- declare namespace ActionGroup_d_exports {
5
- export { Group, Implementation, make };
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
- export interface Group<Actions extends ReadonlyArray<Any>> {
10
- readonly actions: Actions;
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, Exclude<RX, Scope.Scope>>;
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 const make: <const Actions extends ReadonlyArray<Any>>(...actions: Actions) => Group<Actions>;
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 { type Implementation, ActionGroup_d_exports as t };
29
+ export type { Implementation };
@@ -1,27 +1,25 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
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
- var ActionGroup_exports = /* @__PURE__ */ __exportAll({ make: () => make });
6
- /** Duplicate action and MCP names fail at definition time. */
7
- const make = (...actions) => {
8
- const names = /* @__PURE__ */ new Set();
9
- const toolNames = /* @__PURE__ */ new Set();
10
- for (const action of actions) {
11
- if (names.has(action.name)) throw new Error(`Duplicate action: ${action.name}`);
12
- names.add(action.name);
13
- if (action.mcp !== false) {
14
- if (toolNames.has(action.mcp.name)) throw new Error(`Duplicate MCP tool: ${action.mcp.name}`);
15
- toolNames.add(action.mcp.name);
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(actions, built);
19
+ return Implementation.make(group, built);
23
20
  }
24
21
  };
25
- };
22
+ return group;
23
+ }
26
24
  //#endregion
27
- export { make, ActionGroup_exports as t };
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 };