@gjermundgaraba/effect-actions 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +57 -50
  2. package/dist/Action.d.mts +285 -79
  3. package/dist/Action.mjs +124 -23
  4. package/dist/ActionCli.d.mts +102 -31
  5. package/dist/ActionCli.mjs +114 -16
  6. package/dist/ActionHttp.d.mts +170 -51
  7. package/dist/ActionHttp.mjs +130 -79
  8. package/dist/ActionMcp.d.mts +71 -21
  9. package/dist/ActionMcp.mjs +108 -81
  10. package/dist/ActionToolkit.d.mts +56 -27
  11. package/dist/ActionToolkit.mjs +16 -5
  12. package/dist/Authentication.d.mts +129 -33
  13. package/dist/Authentication.mjs +231 -38
  14. package/dist/Testing.d.mts +82 -57
  15. package/dist/Testing.mjs +89 -79
  16. package/dist/internal/actions.d.mts +5 -25
  17. package/dist/internal/actions.mjs +75 -27
  18. package/dist/internal/authentication.d.mts +102 -0
  19. package/dist/internal/authentication.mjs +11 -0
  20. package/dist/internal/call.d.mts +12 -0
  21. package/dist/internal/call.mjs +12 -0
  22. package/dist/internal/cli.d.mts +36 -22
  23. package/dist/internal/cli.mjs +311 -33
  24. package/dist/internal/client.d.mts +43 -0
  25. package/dist/internal/client.mjs +25 -0
  26. package/dist/internal/console.mjs +77 -0
  27. package/dist/internal/declared.d.mts +13 -0
  28. package/dist/internal/declared.mjs +34 -0
  29. package/dist/internal/errors.d.mts +77 -0
  30. package/dist/internal/errors.mjs +79 -0
  31. package/dist/internal/implementation.d.mts +171 -41
  32. package/dist/internal/implementation.mjs +147 -29
  33. package/dist/internal/mcp.d.mts +11 -0
  34. package/dist/internal/mcp.mjs +52 -0
  35. package/dist/internal/memory.d.mts +6 -0
  36. package/dist/internal/memory.mjs +38 -0
  37. package/dist/internal/refusal.mjs +67 -0
  38. package/dist/internal/schema-errors.mjs +20 -0
  39. package/dist/internal/tools.mjs +27 -57
  40. package/docs/Action.md +401 -73
  41. package/docs/ActionCli.md +280 -51
  42. package/docs/ActionHttp.md +266 -82
  43. package/docs/ActionMcp.md +149 -104
  44. package/docs/ActionToolkit.md +97 -35
  45. package/docs/Authentication.md +369 -62
  46. package/docs/CONTEXT.md +23 -36
  47. package/docs/README.md +29 -73
  48. package/docs/Testing.md +154 -77
  49. package/docs/guarantees.md +81 -43
  50. package/docs/setup.md +90 -27
  51. package/package.json +9 -17
  52. package/dist/ActionCatalog.d.mts +0 -30
  53. package/dist/ActionCatalog.mjs +0 -39
  54. package/dist/ActionCliClient.d.mts +0 -48
  55. package/dist/ActionCliClient.mjs +0 -43
  56. package/dist/ActionGroup.d.mts +0 -44
  57. package/dist/ActionGroup.mjs +0 -35
  58. package/dist/ActionHttpClient.d.mts +0 -33
  59. package/dist/ActionHttpClient.mjs +0 -16
  60. package/dist/TestingClient.d.mts +0 -17
  61. package/dist/TestingClient.mjs +0 -22
  62. package/dist/internal/fetchClient.d.mts +0 -6
  63. package/dist/internal/fetchClient.mjs +0 -8
  64. package/dist/internal/tools.d.mts +0 -22
  65. package/docs/ActionCatalog.md +0 -54
  66. package/docs/ActionCliClient.md +0 -63
  67. package/docs/ActionGroup.md +0 -128
  68. package/docs/ActionHttpClient.md +0 -80
@@ -1,43 +0,0 @@
1
- import { selectNamed } from "./internal/actions.mjs";
2
- import { command as command$1 } from "./internal/cli.mjs";
3
- import { Effect } from "effect";
4
- import { HttpClient } from "effect/http";
5
- import { HttpApiClient } from "effect/http-api";
6
- import { Command } from "effect/cli";
7
- //#region src/ActionCliClient.ts
8
- /**
9
- * The native endpoint builder needs string selectors for a runtime-selected route.
10
- * `http.groups` is checked before this widening, and `NativeMethod` restores the
11
- * corresponding precise endpoint type at that one dynamic client boundary.
12
- */
13
- const erasedApi = (api) => {
14
- return api;
15
- };
16
- /**
17
- * Project one action of the binding into a native Effect CLI command.
18
- * Group and action selectors resolve exclusively from `http.groups`; runtime guards
19
- * keep dynamically supplied selector strings from reaching the native client.
20
- */
21
- const command = (http, groupName, actionName, options) => {
22
- const group = selectNamed(http.groups, groupName, `HTTP group "${groupName}"`);
23
- const action = selectNamed(group.actions, actionName, `HTTP action "${groupName}.${actionName}"`);
24
- return command$1(action, (input) => Effect.flatMap(HttpClient.HttpClient, (httpClient) => {
25
- const endpoint = HttpApiClient.endpoint(erasedApi(http.api), {
26
- ...options?.connection,
27
- group: groupName,
28
- endpoint: actionName,
29
- httpClient
30
- });
31
- return Effect.flatMap(endpoint, (method) => {
32
- return method({ payload: input });
33
- });
34
- }), options);
35
- };
36
- /** Project every action of one bound group below its group namespace. */
37
- const group = (http, groupName, options) => {
38
- const actions = selectNamed(http.groups, groupName, `HTTP group "${groupName}"`);
39
- const commands = actions.actions.map((action) => options?.connection === void 0 ? command(http, groupName, action.name) : command(http, groupName, action.name, { connection: options.connection }));
40
- return Command.make(options?.name ?? actions.name).pipe(Command.withSubcommands(commands));
41
- };
42
- //#endregion
43
- export { command, group };
@@ -1,44 +0,0 @@
1
- import { Action, Action_d_exports, Any as Any$1, Codec, Handler } from "./Action.mjs";
2
- import { Actions, SchemaErrorAnswer, SchemaErrorPolicy } from "./internal/actions.mjs";
3
- import { Implementation } from "./internal/implementation.mjs";
4
- import { Effect, Scope, Types } from "effect";
5
- declare namespace ActionGroup_d_exports {
6
- export { Any, Contracts, Group, Implementation, Options, SchemaErrorAnswer, SchemaErrorPolicy, contracts, make };
7
- }
8
- type HandlersFrom<Actions extends ReadonlyArray<Any$1>> = { readonly [A in Actions[number] as A["name"]]: Handler<A, any>; };
9
- /** Group-level errors join each action's own, so a shared set is declared once. */
10
- 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 Access, infer Mcp> ? Action<Name, Input, Output, readonly [...Own, ...Errors], Access, Mcp> : never; };
11
- /** What `make` needs to define a group. */
12
- export interface Options<Name extends string, Errors extends ReadonlyArray<Codec>, Invalid extends Codec = never, Internal extends Codec = never> {
13
- /** The `HttpApiGroup` identifier, and so the OpenAPI tag and operation-ID prefix. */
14
- readonly name: Name;
15
- /** Failures every action of the group may have, added to each action's own. */
16
- readonly errors?: Errors;
17
- /**
18
- * How HTTP answers a request that fails decoding (`invalid`) and a result that fails
19
- * encoding (`internal`); without one, both are Effect's empty 400. MCP keeps its
20
- * native answers.
21
- */
22
- readonly schemaError?: SchemaErrorPolicy<Invalid, Internal>;
23
- }
24
- /** A named set of action contracts: what adapters serve and what `implement` binds. */
25
- export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>, PolicyError extends Codec = Codec> extends Actions<Name, Actions$1, PolicyError> {
26
- /** Bind every handler at once, resolving build-time services once per adapter layer. */
27
- readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never>(build: H | Effect.Effect<H, EX, RX>) => Implementation<Group<Name, Actions$1, PolicyError>, H, NoInfer<EX>, NoInfer<Exclude<RX, Scope.Scope>>>;
28
- }
29
- /** Any group, with its actions and error schemas erased. */
30
- export type Any = Group<string, ReadonlyArray<Any$1>, Codec>;
31
- /** Define a group. Invalid or duplicate action and MCP names fail here, at definition time. */
32
- export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [], Invalid extends Codec = never, Internal extends Codec = never>(options: Options<Name, Errors, Invalid, Internal>, ...actions: Actions): Group<Name, WithErrors<Actions, Errors>, Invalid | Internal>;
33
- /** `G extends unknown` distributes, so each group maps only its own actions. */
34
- type PerGroup<G extends Actions> = G extends unknown ? { readonly [A in G["actions"][number] as `${G["name"]}.${A["name"]}`]: A; } : never;
35
- /** Every action of `Groups`, keyed `<group>.<action>`: the per-group maps merged into one. */
36
- export type Contracts<Groups extends ReadonlyArray<Actions>> = Types.UnionToIntersection<PerGroup<Groups[number]>>;
37
- /**
38
- * Project groups into one map of their action contracts, keyed `<group>.<action>`:
39
- * the identity routes, tools and commands share. Deriving it from the groups
40
- * themselves means an added action cannot be missed.
41
- */
42
- export declare function contracts<const Groups extends ReadonlyArray<Actions>>(...groups: Groups): Contracts<Groups>;
43
- //#endregion
44
- export { ActionGroup_d_exports, type Implementation, type SchemaErrorAnswer, type SchemaErrorPolicy };
@@ -1,35 +0,0 @@
1
- import { assertDistinct, assertName } from "./internal/actions.mjs";
2
- import { Implementation } from "./internal/implementation.mjs";
3
- import { Effect, Predicate } from "effect";
4
- //#region src/ActionGroup.ts
5
- /** Refuse a record without an own-property function for every action, before anything is served. */
6
- const assertHandlers = (group, handlers) => {
7
- const missing = group.actions.filter((action) => !(Object.hasOwn(handlers, action.name) && Predicate.isFunction(handlers[action.name])));
8
- if (missing.length > 0) throw new Error(`Missing handlers for group "${group.name}": ${missing.map((action) => action.name).join(", ")}`);
9
- return handlers;
10
- };
11
- function make(options, ...declared) {
12
- const { name } = options;
13
- assertName("action group name", name);
14
- const actions = declared.map((action) => ({
15
- ...action,
16
- errors: [...action.errors, ...options.errors ?? []]
17
- }));
18
- assertDistinct("action", actions.map((action) => action.name));
19
- const group = {
20
- name,
21
- actions,
22
- schemaError: options.schemaError,
23
- implement: (build) => {
24
- const built = Effect.isEffect(build) ? Effect.map(build, (handlers) => assertHandlers(group, handlers)) : Effect.succeed(assertHandlers(group, build));
25
- return Implementation.make(group, built);
26
- }
27
- };
28
- return group;
29
- }
30
- function contracts(...groups) {
31
- assertDistinct("contract group", groups.map((group) => group.name));
32
- return Object.fromEntries(groups.flatMap((group) => group.actions.map((action) => [`${group.name}.${action.name}`, action])));
33
- }
34
- //#endregion
35
- export { contracts, make };
@@ -1,33 +0,0 @@
1
- import { Action_d_exports, Any, Codec } from "./Action.mjs";
2
- import { Actions } from "./internal/actions.mjs";
3
- import { ActionHttp_d_exports, Http } from "./ActionHttp.mjs";
4
- import { ClientOptions } from "./internal/fetchClient.mjs";
5
- //#region src/ActionHttpClient.d.ts
6
- /**
7
- * The native `HttpApiClient.make` options except `transformResponse`, plus `fetch`;
8
- * see the options table in docs/ActionHttpClient.md.
9
- */
10
- export interface Options extends Omit<ClientOptions, "transformResponse"> {
11
- readonly fetch?: typeof globalThis.fetch;
12
- }
13
- /**
14
- * One action as a Promise of its decoded success. An action whose input may be empty,
15
- * such as one declared without `input`, may be called without an argument.
16
- */
17
- export type Method<A extends Any> = {} extends A["input"]["Type"] ? (input?: A["input"]["Type"]) => Promise<A["success"]["Type"]> : (input: A["input"]["Type"]) => Promise<A["success"]["Type"]>;
18
- /** Every action of the binding, as `client.<group>.<action>(input)`. */
19
- export type Client<Groups extends ReadonlyArray<Actions>> = { readonly [G in Groups[number] as [G["actions"][number]] extends [never] ? never : G["name"]]: { readonly [A in G["actions"][number] as A["name"]]: Method<A>; }; };
20
- /**
21
- * A Promise client for an HTTP binding, for code that does not run Effects, such as a
22
- * browser page. It is Effect's native `HttpApiClient` over `fetch`, built once, and
23
- * each call runs one request: nothing is retried.
24
- *
25
- * A call resolves with the action's decoded success. It rejects with what the native
26
- * client fails with: a declared error value (the action's, its group's schema-error
27
- * policy's, or the binding's surface `errors`), a native `HttpClientError` when the
28
- * server could not be reached or answered with a status or body the contract does not
29
- * declare, or a `SchemaError` when the input does not encode or the success does not
30
- * decode.
31
- */
32
- export declare function promise<const Groups extends ReadonlyArray<Actions>, const Errors extends ReadonlyArray<Codec>>(http: Http<Groups, Errors>, options?: Options): Client<Groups>;
33
- //#endregion
@@ -1,16 +0,0 @@
1
- import { fetchApiClient } from "./internal/fetchClient.mjs";
2
- import { Effect } from "effect";
3
- //#region src/ActionHttpClient.ts
4
- function promise(http, { baseUrl, transformClient, fetch } = {}) {
5
- const send = fetch ?? ((input, init) => globalThis.fetch(input, init));
6
- const native = Effect.runSync(fetchApiClient(http.api, send, {
7
- baseUrl,
8
- transformClient
9
- }));
10
- return Object.fromEntries(Object.entries(native).map(([group, endpoints]) => [group, Object.fromEntries(Object.entries(endpoints).map(([action, endpoint]) => {
11
- const method = (input) => Effect.runPromise(endpoint({ payload: input ?? {} }));
12
- return [action, method];
13
- }))]));
14
- }
15
- //#endregion
16
- export { promise };
@@ -1,17 +0,0 @@
1
- import { Client } from "@modelcontextprotocol/client";
2
- //#region src/TestingClient.d.ts
3
- /** How `withMcpClient` connects the official client. */
4
- export interface McpClientOptions {
5
- /** A web handler, such as `HttpRouter.toWebHandler(routes).handler`. */
6
- readonly fetch: (request: Request) => Promise<Response>;
7
- readonly path: string;
8
- /** Defaults to `http://localhost`. */
9
- readonly baseUrl?: string | URL;
10
- readonly headers?: ConstructorParameters<typeof Headers>[0];
11
- }
12
- /**
13
- * Connect an official client pinned to MCP 2026-07-28, the revision `ActionMcp`
14
- * serves, and always close its transport after the callback.
15
- */
16
- export declare const withMcpClient: <A>({ fetch, path, baseUrl, headers }: McpClientOptions, run: (client: Client) => Promise<A>) => Promise<A>;
17
- //#endregion
@@ -1,22 +0,0 @@
1
- import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
2
- //#region src/TestingClient.ts
3
- /**
4
- * Connect an official client pinned to MCP 2026-07-28, the revision `ActionMcp`
5
- * serves, and always close its transport after the callback.
6
- */
7
- const withMcpClient = async ({ fetch, path, baseUrl = "http://localhost", headers }, run) => {
8
- const client = new Client({
9
- name: "test",
10
- version: "0"
11
- }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
12
- try {
13
- const transportOptions = { fetch: (input, init) => fetch(new Request(input, init)) };
14
- if (headers !== void 0) transportOptions.requestInit = { headers };
15
- await client.connect(new StreamableHTTPClientTransport(new URL(path, baseUrl), transportOptions));
16
- return await run(client);
17
- } finally {
18
- await client.close();
19
- }
20
- };
21
- //#endregion
22
- export { withMcpClient };
@@ -1,6 +0,0 @@
1
- import { Effect } from "effect";
2
- import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/http-api";
3
- //#region src/internal/fetchClient.d.ts
4
- /** The native `HttpApiClient.make` options: `baseUrl`, `transformClient`, `transformResponse`. */
5
- export type ClientOptions = NonNullable<Parameters<typeof HttpApiClient.make>[1]>;
6
- //#endregion
@@ -1,8 +0,0 @@
1
- import { Effect } from "effect";
2
- import { FetchHttpClient } from "effect/http";
3
- import { HttpApiClient } from "effect/http-api";
4
- //#region src/internal/fetchClient.ts
5
- /** The native grouped `HttpApiClient` of `api`, sending every request through `fetch`. */
6
- const fetchApiClient = (api, fetch, options) => HttpApiClient.make(api, options).pipe(Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.Fetch, fetch));
7
- //#endregion
8
- export { fetchApiClient };
@@ -1,22 +0,0 @@
1
- import { Action_d_exports, Any, Codec } from "../Action.mjs";
2
- import "./implementation.mjs";
3
- import { Context, Effect, Layer } from "effect";
4
- import { Tool, Toolkit } from "effect/ai";
5
- //#region src/internal/tools.d.ts
6
- /** What a tool surface binds around the implementations it projects. */
7
- export interface SurfaceOptions<Errors extends ReadonlyArray<Codec>, R> {
8
- /**
9
- * Failures the surface answers with instead of a handler: authorization, rate
10
- * limits. Declared on every tool, so a refusal is returned exactly like an
11
- * action's own error.
12
- */
13
- readonly errors?: Errors;
14
- /**
15
- * Runs once per tool call, after the arguments are decoded and before the
16
- * selected handler, with the action contract it is about to run. It fails with
17
- * this surface's `errors`. Its services are request-time requirements, like a
18
- * handler's.
19
- */
20
- readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
21
- }
22
- //#endregion
@@ -1,54 +0,0 @@
1
- # ActionCatalog
2
-
3
- An offline JSON document of contract identities, metadata and encoded-value JSON Schemas.
4
- Built from contracts alone: no implementation, service or server is involved.
5
-
6
- ## API
7
-
8
- Import `@gjermundgaraba/effect-actions/ActionCatalog`.
9
-
10
- `make(...groups)` returns `Catalog`: `version: "4"` and an ordered `actions` array of `Entry`.
11
-
12
- | Entry field | Meaning |
13
- | ------------------------------------ | ------------------------------------------------------------------- |
14
- | `id`, `group`, `name`, `description` | Stable `<group>.<action>` identity and contract metadata. |
15
- | `mcp` | Resolved MCP enablement, name, hints and text field. |
16
- | `input`, `success` | Standalone draft 2020-12 JSON Schema objects, not wrappers. |
17
- | `errors` | JSON Schema objects for own and inherited group errors. |
18
- | `httpSchemaErrors` | JSON Schema objects for HTTP policy errors; empty without a policy. |
19
-
20
- Exported types are `Catalog` and `Entry`.
21
-
22
- ## Canonical
23
-
24
- ```ts
25
- import { NodeRuntime } from "@effect/platform-node";
26
- import { Console } from "effect";
27
- import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
28
- import { AuditActions, PublicActions, UserActions } from "./contracts.js";
29
-
30
- // Contract inspection requires no implementation or domain-service Layer.
31
- Console.log(
32
- JSON.stringify(ActionCatalog.make(PublicActions, UserActions, AuditActions), null, 2),
33
- ).pipe(NodeRuntime.runMain);
34
- ```
35
-
36
- ## Rules
37
-
38
- - Takes groups, not implementations. Nothing is acquired or started.
39
- - Group names must be unique. Entries preserve group declaration order, then action declaration order.
40
- - `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
41
- - Schemas describe encoded action values through native `Schema.toJsonSchemaDocument`, which lowers to the JSON codec first. Objects are closed (`additionalProperties: false`), as in the OpenAPI document. A `Date` is a string, an `Option` a tagged union, a `bigint` a string of digits. These are whole action values, as HTTP sends them, not MCP's split of a text field or deployment URLs.
42
- - JSON Schema does not preserve arbitrary Effect transformations or filters. Checks on the decoded side of a transformation, such as `FiniteFromString.check(isGreaterThan(0))`, are not described. The server's decode remains authoritative.
43
- - Each schema root includes its own `$schema` and, where needed, `$defs`. Equal identifiers across entries or fields cannot replace each other; recursive references resolve locally.
44
- - The catalog contains JSON Schema only. Use Effect's `SchemaRepresentation` directly when native schema persistence or revival is needed.
45
- - `errors` includes group-level errors. HTTP schema-policy errors are separate in `httpSchemaErrors`. Surface errors belong to bindings and are not listed.
46
- - `access` is omitted; read it from the contract when needed. Actions hidden from MCP are included. Presence grants no authorization or proof that an endpoint is mounted; the host decides what to publish.
47
- - No search, catalog HTTP endpoint, or TypeScript code generation is provided.
48
-
49
- ## Failure modes
50
-
51
- - `Duplicate catalog group: <name>`: two supplied groups share a name, including a group passed twice.
52
- - Type error passing an implementation: pass `app.group` or the group value itself.
53
- - Native JSON Schema conversion throws: the codec or its annotations cannot be converted.
54
- - JSON Schema accepts a value the server rejects: custom validation or a decoded-side check is not expressible in the wire schema. Server validation remains authoritative.
@@ -1,63 +0,0 @@
1
- # ActionCliClient
2
-
3
- Native Effect CLI commands that call the HTTP API through Effect's `HttpApiClient`. Runs no
4
- handler locally and manages no credentials.
5
-
6
- ## API
7
-
8
- Import `@gjermundgaraba/effect-actions/ActionCliClient`.
9
-
10
- | API | Purpose |
11
- | ------------------------------------------------ | -------------------------------------------------------------------------- |
12
- | `command(http, groupName, actionName, options?)` | Call one action of the binding through a native Effect `Command`. |
13
- | `group(http, groupName, options?)` | Every action of a bound group under one command. |
14
- | `Options`, `GroupOptions`, `Connection` | Single/aggregate command configuration and native HTTP connection options. |
15
-
16
- Single-command options: `name`, `render`, paired `parameters`/`input`, and `connection`.
17
- Group options: `name` and `connection`. Parsing/rendering semantics match [ActionCli.md](ActionCli.md).
18
- `connection` accepts native `HttpApiClient.make` configuration, including `baseUrl`,
19
- `transformClient` and `transformResponse`.
20
-
21
- Remote commands have no local `before` hook: the server owns authorization. Their Effects
22
- retain the selected endpoint's client failures and middleware requirements, and require an
23
- `HttpClient` supplied by the host. Group/action selectors stay checked against the bound API.
24
-
25
- ## Canonical
26
-
27
- ```ts
28
- import { Command } from "effect/cli";
29
- import { Console, Effect, Logger } from "effect";
30
- import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
31
- import * as ActionCliClient from "@gjermundgaraba/effect-actions/ActionCliClient";
32
- import { Http } from "./contracts.js";
33
-
34
- const command = ActionCliClient.command(Http, "public", "status", {
35
- connection: { baseUrl: "http://127.0.0.1:3000" },
36
- });
37
-
38
- Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
39
- Effect.tapCause((cause) => Console.error(cause)),
40
- Effect.provideService(Logger.LogToStderr, true),
41
- // The host supplies the native client and its connection configuration.
42
- Effect.provide(NodeHttpClient.layerUndici),
43
- Effect.provide(NodeServices.layer),
44
- NodeRuntime.runMain({ disableErrorReporting: true }),
45
- );
46
- ```
47
-
48
- Authentication: `connection: { transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)) }`.
49
-
50
- ## Rules
51
-
52
- - Selectors resolve from `http.groups` only. `groupName` must be a bound group; `actionName` must be an action of that group. Runtime guards reject dynamically supplied strings that the types would not accept.
53
- - The host provides `HttpClient` and its configuration. Endpoint selection, credentials, and storage are the host's; nothing is inferred from action arguments.
54
- - Input (`--input`, `--input-file`, or `input(parsed)`) is encoded JSON. The command decodes it with the action schema, and the native client re-encodes it with `Http.api`'s closed payload codec.
55
- - Input syntax (`--input`, `--input-file`, or `parameters`) and output (`render` and its `--json` flag) are exactly as in `ActionCli`.
56
- - Errors are the client's: declared errors (the binding's `errors` included), policy errors, `SchemaError`, and `HttpClientError`.
57
-
58
- ## Failure modes
59
-
60
- - Type error on `actionName`: the action is not in that group, or the group is not bound in this `Http`.
61
- - `HttpClient` unsatisfied (a type error at `runMain`): provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`) to the runtime.
62
- - `HttpClientError` with `reason._tag` `InvalidUrlError`: `connection.baseUrl` is absent; it has no default. Connection refused: `baseUrl` points at the wrong host or port.
63
- - 401 from the server: add `transformClient` to `connection`. The command adds no headers on its own.
@@ -1,128 +0,0 @@
1
- # ActionGroup
2
-
3
- A named set of actions. The group is what adapters serve, what clients are grouped by, and
4
- what `implement` binds handlers to. Shared errors and the HTTP schema-error policy live here.
5
- The pre-handler hook does not: each surface binds its own (see [guarantees.md](guarantees.md)).
6
-
7
- ## API
8
-
9
- Import `@gjermundgaraba/effect-actions/ActionGroup`.
10
-
11
- | API | Purpose |
12
- | --------------------------------- | --------------------------------------------------------------------------------------- |
13
- | `make(options, ...actions)` | Group actions under a required `name`; optional `errors` are inherited by each action. |
14
- | `group.implement(recordOrEffect)` | Bind every action handler; an Effect builder carries startup requirements and failures. |
15
- | `app.group`, `app.build` | Bound contract and scoped handler acquisition. |
16
- | `contracts(...groups)` | Exact action types keyed by `<group>.<action>`. |
17
-
18
- Exported types: `Group`, `Any`, `Options`, `Implementation`, `Contracts`, `SchemaErrorPolicy`,
19
- `SchemaErrorAnswer`.
20
-
21
- The optional `schemaError` group option is a `SchemaErrorPolicy`: two answers, `invalid` and
22
- `internal`, each a `schema` (a declared error codec) and `make(failure)` (the native
23
- `HttpApiSchemaError` to that error's decoded value). The library decides which answer applies.
24
- It affects HTTP only. Handler records require every action key. Each handler's input, success
25
- and declared errors come from its action; request requirements stay distinct from the
26
- builder's requirements.
27
-
28
- ## Canonical
29
-
30
- ```ts
31
- import { Effect, Schema } from "effect";
32
- import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
33
- import { CurrentActor } from "./auth.js";
34
- import { Double, GetUser, RenameUser, WhoAmI } from "./contracts.js";
35
- import { Users } from "./users.js";
36
-
37
- class InvalidRequest extends Schema.TaggedError<InvalidRequest>()(
38
- "InvalidRequest",
39
- { message: Schema.String },
40
- { httpApiStatus: 400 },
41
- ) {}
42
- class InternalError extends Schema.TaggedError<InternalError>()(
43
- "InternalError",
44
- { message: Schema.String },
45
- { httpApiStatus: 500 },
46
- ) {}
47
-
48
- // A request that does not decode is the caller's fault; a result that does not
49
- // encode is the server's. Shared across groups as a constant.
50
- const schemaError = {
51
- invalid: {
52
- schema: InvalidRequest,
53
- make: () => new InvalidRequest({ message: "The request does not match the action's input." }),
54
- },
55
- internal: {
56
- schema: InternalError,
57
- make: () => new InternalError({ message: "The request could not be completed." }),
58
- },
59
- };
60
-
61
- export const MathActions = ActionGroup.make({ name: "math" }, Double);
62
-
63
- export const UserActions = ActionGroup.make(
64
- { name: "users", schemaError },
65
- GetUser,
66
- RenameUser,
67
- WhoAmI,
68
- );
69
-
70
- // Build-time services (Users) are yielded in the builder, once per adapter layer.
71
- // Request-time services (CurrentActor) are yielded inside handlers. Authorization
72
- // is not here: each adapter binds one `before` hook for every group it serves.
73
- export const UserApp = UserActions.implement(
74
- Effect.gen(function* () {
75
- const users = yield* Users;
76
-
77
- return {
78
- getUser: ({ id }) => Effect.flatMap(CurrentActor, (actor) => users.get(actor.tenantId, id)),
79
- renameUser: ({ id, name }) =>
80
- Effect.flatMap(CurrentActor, (actor) => users.rename(actor, id, name)),
81
- whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
82
- };
83
- }),
84
- );
85
-
86
- // No build-time services: pass the record directly.
87
- export const MathApp = MathActions.implement({ double: ({ value }) => Effect.succeed(value * 2) });
88
-
89
- // One typed map of every contract, keyed "<group>.<action>".
90
- export const contracts = ActionGroup.contracts(UserActions, MathActions);
91
- const renames: "renameUser" = contracts["users.renameUser"].name;
92
- ```
93
-
94
- ## Rules
95
-
96
- - `name` matches `[A-Za-z0-9_-]+` and cannot be `then`. Action names are unique within the group. Group and MCP tool names are checked where they are served; see [guarantees.md](guarantees.md#namespaces).
97
- - Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them. A failure that only the surface produces belongs on the adapter instead (`ActionHttp.make`'s `errors`, `ActionMcp`'s and `ActionToolkit`'s `errors`), not here.
98
- - `implement` takes a complete plain record or an Effect producing one. Every action must have a handler; missing keys are compile errors. A handler is an own-property function of the record: an inherited one, such as a class instance's method, does not count. A record that still lacks one at runtime (plain JavaScript, a cast: a missing key, `undefined`, any other non-function, an inherited method) throws at `implement`; a builder's record fails the adapter layer's build. Nothing is served with a handler missing. Handlers are called without a receiver. Additional keys are not dispatched. It takes nothing else.
99
- - The builder Effect runs once per adapter layer that serves the implementation, in that layer's scope, and once per invocation under `ActionCli`. An implementation served by HTTP and MCP is built twice. Acquire shared state in a Layer you provide to the adapters, not in the builder.
100
- - Services yielded in the builder are build-time requirements (`RX`). Services yielded in a handler are request-time requirements (`R` of the handler). Adapters keep these separate in their types. Use distinct tags for each kind; never provide a request-identity tag at startup (see [guarantees.md](guarantees.md)).
101
- - `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
102
- - `build` exists for direct handler tests under `Effect.scoped`. It bypasses transport validation and the hook, and is not a substitute for adapter tests. There is no public handler service or Layer.
103
- - `contracts(...groups)` returns one record keyed `<group>.<action>`, with each action's exact contract type. Duplicate group names throw. Use it where routes, tools, jobs or commands must name an action: the key set is derived, so an added action cannot be missed.
104
-
105
- ### Schema-error policy
106
-
107
- - Without a policy, HTTP answers decoding and encoding failures with Effect's native empty 400.
108
- - With a policy, the library picks the answer from the native `HttpApiSchemaError`'s `kind`. Request-side kinds (`Payload`, `Params`, `Headers`, `Query`: the request did not decode) get `invalid`; response-side kinds (`Body`, `ResponseHeaders`: the handler's result did not encode) get `internal`. `make` receives the failure (`kind`, `cause`) and returns its schema's value. HTTP uses that error's `httpApiStatus`, and both errors appear in `Http.api`, in clients, and in OpenAPI.
109
- - In practice only `Payload` and `Body` occur: actions declare no parameters, query, or headers, and no response headers.
110
- - `invalid` and `internal` may share one schema; it is declared once.
111
- - MCP is unaffected. The native `McpServer` answers invalid arguments and unencodable results itself.
112
- - Policy errors extend the transport contract, not the handler contract. A handler cannot return them.
113
- - The policy runs only on the server. Client-side codec failures stay `SchemaError`.
114
- - HTTP decodes input with `errors: "all"`, so an `invalid` `cause` carries every issue. Issues never retain the rejected values.
115
- - Groups served by one adapter may have different policies; each action answers with its own group's.
116
- - Input schema-error policies run before the `before` hook. Invalid input is answered by the policy without invoking the hook or handler.
117
- - Domain errors, defects, interruptions, and protocol errors are not remapped. An unencodable declared error is a defect. A broken policy error is not recursively remapped.
118
-
119
- ## Failure modes
120
-
121
- - Throws at `make`: `Duplicate action: <name>` or `Invalid action group name: <name>`.
122
- - `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
123
- - Handler compiles but returns an error not in `errors`: type error on the handler's error channel. Declare it on the action or the group.
124
- - `Expected 1 arguments, but got 2` at `implement`: the second options object is gone. Move `before` to the adapter that serves the group.
125
- - `Parameter 'failure' implicitly has an 'any' type` in a standalone policy constant: a `make` that reads the failure is typed only inline. Annotate the parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/http-api`), or annotate the constant as `ActionGroup.SchemaErrorPolicy<typeof Invalid, typeof Internal>`.
126
- - `Missing handlers for group "<group>": <actions>` thrown at `implement`, or failing the layer build: the handler record has no own-property function for those actions. Add them to the record itself, not to a prototype.
127
- - A service is resolved once and shared across requests when it should be per request: it was yielded in the builder. Move the `yield*` into the handler.
128
- - `Duplicate contract group` thrown by `contracts`: two groups share a name, so their keys would collide. Rename one.
@@ -1,80 +0,0 @@
1
- # ActionHttpClient
2
-
3
- A Promise client for an HTTP binding, for code that does not run Effects, such as a browser
4
- page. It is Effect's native `HttpApiClient` over `fetch`, built once; each call is one request.
5
-
6
- ## API
7
-
8
- Import `@gjermundgaraba/effect-actions/ActionHttpClient`.
9
-
10
- | API | Purpose |
11
- | ------------------------- | --------------------------------------------------------------------- |
12
- | `promise(http, options?)` | `client.<group>.<action>(input)` for every action of the binding. |
13
- | `Options` | Where and how requests are sent. |
14
- | `Client`, `Method` | The client's type, derived from the binding, and one action's method. |
15
-
16
- The options are the native `HttpApiClient.make` options except `transformResponse`, plus `fetch`.
17
- `transformResponse` may change a call's success, failure or required services, which neither
18
- the method types nor a Promise can follow; wrap `fetch` or use `transformClient` instead.
19
-
20
- | Option | Meaning |
21
- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------- |
22
- | `baseUrl` | What routes are resolved against, such as `https://api.example.com`. Omitted: relative routes (the page's origin in a browser). |
23
- | `transformClient` | Wraps the native `HttpClient`. A bearer token: `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. |
24
- | `fetch` | The transport. Defaults to the global `fetch`, looked up on each call. |
25
-
26
- A method takes the action's decoded input and resolves with its decoded success. An action
27
- whose input may be empty (no `input`, or only optional fields) may be called without an argument.
28
-
29
- ## Canonical
30
-
31
- ```ts
32
- import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
33
- import * as ActionHttpClient from "@gjermundgaraba/effect-actions/ActionHttpClient";
34
- import { Http, UserNotFound } from "./contracts.js";
35
-
36
- // Promises in, Promises out: for code that does not run Effects, such as a browser page.
37
- // The options are the native `HttpApiClient.make` options plus `fetch`; a bearer token is
38
- // a `transformClient`.
39
- const client = ActionHttpClient.promise(Http, {
40
- baseUrl: "http://127.0.0.1:3000",
41
- transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken("alice")),
42
- });
43
-
44
- export const userName = async (id: string): Promise<string> => {
45
- try {
46
- return (await client.users.getUser({ id })).name;
47
- } catch (error) {
48
- // A declared error rejects as its own value...
49
- if (error instanceof UserNotFound) return "(no such user)";
50
-
51
- // ...and what the contract cannot account for as Effect's own `HttpClientError`.
52
- if (HttpClientError.isHttpClientError(error) && error.response === undefined) {
53
- return "(server unreachable)";
54
- }
55
-
56
- throw error;
57
- }
58
- };
59
- ```
60
-
61
- Other headers also go through `transformClient` (`HttpClient.mapRequest(HttpClientRequest.setHeader("x-agent", agent))`).
62
- Reacting to a response every call may meet (a proxy's 401), or a token read per call, goes through
63
- `fetch`: `fetch: (input, init) => { const request = new Request(input, init); request.headers.set("authorization", "Bearer " + readToken()); return fetch(request); }`.
64
-
65
- ## Rules
66
-
67
- - A call rejects with exactly what the native client's Effect fails with. Declared errors arrive as their decoded values: the action's own (its group's `errors` included), its group's schema-error policy's, and the binding's surface `errors`. Classify them with `instanceof`, their `_tag`, or `Schema.is`.
68
- - Anything the contract does not account for rejects with Effect's own error. `HttpClientError`: the server could not be reached (`response` is `undefined`), or answered with a status no schema declares, or with a body that could not be read. `SchemaError`: the input did not encode (including a field the action does not declare; nothing is sent), or the success body did not decode.
69
- - The library adds no error type of its own and interprets no status. Which failures mean "signed out" or "try again" is the caller's decision.
70
- - Nothing is retried. A rejected write may or may not have happened; only a declared error says what the server did.
71
- - Every action of a bound group has a method. A group without actions has no namespace.
72
- - The client holds no connections or timers. Making one is synchronous and sends nothing.
73
- - A method is not an Effect. Code that runs Effects uses `HttpApiClient.make(Http.api)` directly (see [ActionHttp.md](ActionHttp.md)); its methods take `{ payload }`.
74
-
75
- ## Failure modes
76
-
77
- - Rejects with `HttpClientError` whose `reason._tag` is `DecodeError` (`Decode error (401 POST ...)`) for a 401 or 403: the surface answered with an error the binding does not declare. Add it to `ActionHttp.make`'s `errors`, and have the middleware encode it.
78
- - Rejects with `HttpClientError` whose `reason._tag` is `InvalidUrlError` outside a browser: `baseUrl` is omitted, and there is no page to resolve relative routes against. Set `baseUrl`.
79
- - A stubbed global `fetch` is not used: `fetch` was passed as an option, which takes precedence.
80
- - Property does not exist on the client: the action's group is not bound in this `Http`.