@gjermundgaraba/effect-actions 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -13
- package/dist/ActionCatalog.d.mts +2 -2
- package/dist/ActionCatalog.mjs +11 -10
- package/dist/ActionCli.d.mts +2 -2
- package/dist/ActionCli.mjs +7 -8
- package/dist/ActionCliClient.d.mts +9 -6
- package/dist/ActionCliClient.mjs +9 -11
- package/dist/ActionGroup.d.mts +17 -15
- package/dist/ActionGroup.mjs +12 -1
- package/dist/ActionHttp.d.mts +10 -5
- package/dist/ActionHttp.mjs +21 -38
- package/dist/ActionHttpClient.d.mts +45 -0
- package/dist/ActionHttpClient.mjs +24 -0
- package/dist/ActionMcp.d.mts +17 -43
- package/dist/ActionMcp.mjs +15 -19
- package/dist/ActionToolkit.d.mts +5 -17
- package/dist/ActionToolkit.mjs +2 -7
- package/dist/Testing.d.mts +31 -1
- package/dist/Testing.mjs +50 -2
- package/dist/TestingClient.d.mts +6 -5
- package/dist/TestingClient.mjs +6 -3
- package/dist/internal/actions.d.mts +18 -7
- package/dist/internal/actions.mjs +21 -2
- package/dist/internal/cli.d.mts +2 -2
- package/dist/internal/cli.mjs +34 -20
- package/dist/internal/implementation.d.mts +17 -6
- package/dist/internal/implementation.mjs +12 -3
- package/dist/internal/tools.d.mts +22 -0
- package/dist/internal/tools.mjs +3 -18
- package/docs/Action.md +22 -54
- package/docs/ActionCatalog.md +32 -35
- package/docs/ActionCli.md +25 -52
- package/docs/ActionCliClient.md +16 -30
- package/docs/ActionGroup.md +34 -56
- package/docs/ActionHttp.md +34 -46
- package/docs/ActionHttpClient.md +73 -0
- package/docs/ActionMcp.md +117 -89
- package/docs/ActionToolkit.md +28 -46
- package/docs/Authentication.md +18 -36
- package/docs/CONTEXT.md +6 -5
- package/docs/README.md +30 -24
- package/docs/Testing.md +62 -59
- package/docs/guarantees.md +13 -13
- package/docs/setup.md +7 -6
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -7,7 +7,6 @@ Effect's own servers and clients underneath.
|
|
|
7
7
|
## Looks like this
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
import { McpProtocol } from "effect/unstable/ai";
|
|
11
10
|
import { Effect, Layer, Schema } from "effect";
|
|
12
11
|
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
13
12
|
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
@@ -30,11 +29,12 @@ const app = Actions.implement({
|
|
|
30
29
|
});
|
|
31
30
|
|
|
32
31
|
export const routes = Layer.mergeAll(
|
|
33
|
-
Http.layer(
|
|
34
|
-
ActionMcp.layerHttp(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
Http.layer([app]),
|
|
33
|
+
ActionMcp.layerHttp([app], {
|
|
34
|
+
name: "greetings",
|
|
35
|
+
version: "1.0.0",
|
|
36
|
+
path: "/mcp",
|
|
37
|
+
}),
|
|
38
38
|
);
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -42,7 +42,7 @@ Serve `routes` with Effect's `HttpRouter` and you have `POST /api/actions/greeti
|
|
|
42
42
|
and an MCP tool named `greet` at `/mcp`. The same `app` is also:
|
|
43
43
|
|
|
44
44
|
```ts
|
|
45
|
-
const { toolkit, layer } = ActionToolkit.make(
|
|
45
|
+
const { toolkit, layer } = ActionToolkit.make([app]); // native Effect AI Toolkit and its handler layer
|
|
46
46
|
const cli = ActionCli.group(app); // greetings greet --input '{"name":"Ada"}'
|
|
47
47
|
const remote = ActionCliClient.command(Http, "greetings", "greet"); // same command, over HTTP
|
|
48
48
|
const catalog = ActionCatalog.make(Actions); // offline JSON contract, no handlers acquired
|
|
@@ -58,10 +58,13 @@ const greeting = Effect.gen(function* () {
|
|
|
58
58
|
});
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
Code that does not run Effects, such as a browser page, gets the same calls as Promises:
|
|
62
|
+
`await ActionHttpClient.promise(Http, { baseUrl }).greetings.greet({ name: "Ada" })`.
|
|
63
|
+
|
|
61
64
|
## Why
|
|
62
65
|
|
|
63
66
|
- Input, success, and errors are declared once. Routes, tools, commands, clients, OpenAPI, and the catalog are derived from that declaration, so they cannot disagree.
|
|
64
|
-
- A handler may fail only with the errors its action declares. Each surface binds one `before` hook that runs before every handler it serves —
|
|
67
|
+
- A handler may fail only with the errors its action declares. Each surface binds one `before` hook that runs before every handler it serves — after successful input decoding — so a policy such as scope enforcement is written once per surface and no action of that surface can skip it.
|
|
65
68
|
- The pieces are Effect's own. `Http.api` is a native `HttpApi`, so `OpenApi.fromApi`, Swagger, Scalar, and `HttpApiClient` work on it unchanged. MCP is Effect's native `McpServer`, one `Tool` per action, with no SDK runtime dependency.
|
|
66
69
|
- Every action states its `access` (`"read"` or `"write"`, required), so authorization reads the contract instead of a hand-maintained list of mutation names.
|
|
67
70
|
- Build-time services and per-request services are tracked separately in the types. Middleware is per group, so a public group and an authenticated group can share one mount path.
|
|
@@ -70,10 +73,10 @@ const greeting = Effect.gen(function* () {
|
|
|
70
73
|
## Install
|
|
71
74
|
|
|
72
75
|
```sh
|
|
73
|
-
pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.
|
|
76
|
+
pnpm add @gjermundgaraba/effect-actions effect@4.0.0-rc.117
|
|
74
77
|
```
|
|
75
78
|
|
|
76
|
-
Add `@effect/platform-node@4.0.0-rc.
|
|
79
|
+
Add `@effect/platform-node@4.0.0-rc.117` to serve from Node. Every module is a subpath import
|
|
77
80
|
(`.../Action`, `.../ActionHttp`, ...); there is no package root, so a contracts-only bundle
|
|
78
81
|
never loads a server.
|
|
79
82
|
|
|
@@ -98,13 +101,13 @@ in `node_modules/@gjermundgaraba/effect-actions/docs`) gives the same content.
|
|
|
98
101
|
|
|
99
102
|
## Status
|
|
100
103
|
|
|
101
|
-
The `effect` peer
|
|
102
|
-
|
|
104
|
+
The `effect` peer accepts any Effect 4.0 release candidate from `4.0.0-rc.116` on; the package
|
|
105
|
+
is built and tested against the release candidate in its `devDependencies` (see [docs/setup.md](docs/setup.md)). Actions are unary JSON over HTTP and MCP: no streaming, uploads, prompts, or resources.
|
|
103
106
|
Authentication and authorization belong to the application. See [docs/setup.md](docs/setup.md).
|
|
104
107
|
|
|
105
108
|
## Acknowledgements
|
|
106
109
|
|
|
107
|
-
A few
|
|
110
|
+
A few ideas (like the native Toolkit, CLI, and catalog projections) are inspired by the excellent [rat-stack](https://github.com/joelhooks/rat-stack) by Joel Hooks.
|
|
108
111
|
|
|
109
112
|
## Contributing
|
|
110
113
|
|
package/dist/ActionCatalog.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import { Action_d_exports, Any } from "./Action.mjs";
|
|
|
2
2
|
import { ActionGroup_d_exports, Any as Any$1 } from "./ActionGroup.mjs";
|
|
3
3
|
import { JsonSchema } from "effect";
|
|
4
4
|
//#region src/ActionCatalog.d.ts
|
|
5
|
-
/** One contract, with independently rooted
|
|
5
|
+
/** One contract, with independently rooted descriptions of its encoded values. */
|
|
6
6
|
export interface Entry {
|
|
7
7
|
readonly id: string;
|
|
8
8
|
readonly group: string;
|
|
@@ -19,7 +19,7 @@ export interface Entry {
|
|
|
19
19
|
}
|
|
20
20
|
/** An offline contract document, not an authorization decision or a mounted endpoint. */
|
|
21
21
|
export interface Catalog {
|
|
22
|
-
readonly version: "
|
|
22
|
+
readonly version: "3";
|
|
23
23
|
readonly actions: ReadonlyArray<Entry>;
|
|
24
24
|
}
|
|
25
25
|
/**
|
package/dist/ActionCatalog.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { assertDistinct } from "./internal/actions.mjs";
|
|
1
|
+
import { assertDistinct, policyErrors } from "./internal/actions.mjs";
|
|
2
2
|
import { JsonSchema, Schema } from "effect";
|
|
3
3
|
//#region src/ActionCatalog.ts
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/** Describe the JSON wire form; decoding on the server remains authoritative. */
|
|
5
|
+
const describe = (codec) => {
|
|
6
|
+
const { schema, definitions } = Schema.toJsonSchemaDocument(codec);
|
|
6
7
|
return {
|
|
7
8
|
$schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
|
|
8
|
-
...
|
|
9
|
-
...Object.keys(
|
|
9
|
+
...schema,
|
|
10
|
+
...Object.keys(definitions).length === 0 ? {} : { $defs: definitions }
|
|
10
11
|
};
|
|
11
12
|
};
|
|
12
13
|
/**
|
|
@@ -17,7 +18,7 @@ const jsonSchema = (codec) => {
|
|
|
17
18
|
const make = (...groups) => {
|
|
18
19
|
assertDistinct("catalog group", groups.map((group) => group.name));
|
|
19
20
|
return {
|
|
20
|
-
version: "
|
|
21
|
+
version: "3",
|
|
21
22
|
actions: groups.flatMap((group) => group.actions.map((action) => ({
|
|
22
23
|
id: `${group.name}.${action.name}`,
|
|
23
24
|
group: group.name,
|
|
@@ -25,10 +26,10 @@ const make = (...groups) => {
|
|
|
25
26
|
description: action.description,
|
|
26
27
|
http: action.http,
|
|
27
28
|
mcp: action.mcp,
|
|
28
|
-
input:
|
|
29
|
-
success:
|
|
30
|
-
errors: action.errors.map(
|
|
31
|
-
httpSchemaErrors: action.http ? group.schemaError
|
|
29
|
+
input: describe(action.input),
|
|
30
|
+
success: describe(action.success),
|
|
31
|
+
errors: action.errors.map(describe),
|
|
32
|
+
httpSchemaErrors: action.http && group.schemaError !== void 0 ? policyErrors(group.schemaError).map(describe) : []
|
|
32
33
|
})))
|
|
33
34
|
};
|
|
34
35
|
};
|
package/dist/ActionCli.d.mts
CHANGED
|
@@ -6,8 +6,8 @@ import { Effect, Scope } from "effect";
|
|
|
6
6
|
import { Command } from "effect/unstable/cli";
|
|
7
7
|
//#region src/ActionCli.d.ts
|
|
8
8
|
/**
|
|
9
|
-
* The hook a local command runs before the selected handler. A CLI
|
|
10
|
-
*
|
|
9
|
+
* The hook a local command runs before the selected handler. A CLI does not serialize
|
|
10
|
+
* failures, so its refusal is a typed failure of the command effect rather than a
|
|
11
11
|
* declared surface error; the native parser has already decoded the input.
|
|
12
12
|
*/
|
|
13
13
|
interface BeforeOptions<E, R> {
|
package/dist/ActionCli.mjs
CHANGED
|
@@ -1,26 +1,25 @@
|
|
|
1
|
+
import { selectNamed } from "./internal/actions.mjs";
|
|
1
2
|
import { dispatch } from "./internal/implementation.mjs";
|
|
2
3
|
import { command as command$1 } from "./internal/cli.mjs";
|
|
3
4
|
import { Effect } from "effect";
|
|
4
5
|
import { Command } from "effect/unstable/cli";
|
|
5
6
|
//#region src/ActionCli.ts
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
};
|
|
7
|
+
/**
|
|
8
|
+
* Acquire the implementation in a scope of its own, run one action through the hook
|
|
9
|
+
* and its handler, and release it: every local command, selected or grouped.
|
|
10
|
+
*/
|
|
11
11
|
const local = (app, action, before, input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, before)(input)));
|
|
12
12
|
/**
|
|
13
13
|
* Project one explicitly selected local action into a native Effect CLI command.
|
|
14
14
|
* The action name resolves against the bound group; no HTTP fallback exists.
|
|
15
15
|
*/
|
|
16
16
|
const command = (app, name, options) => {
|
|
17
|
-
const action =
|
|
17
|
+
const action = selectNamed(app.group.actions, name, `action "${app.group.name}.${name}"`);
|
|
18
18
|
return command$1(action, (input) => local(app, action, options?.before, input), options);
|
|
19
19
|
};
|
|
20
20
|
/** Project every local action below its group namespace with default CLI options. */
|
|
21
21
|
const group = (app, options) => {
|
|
22
|
-
const
|
|
23
|
-
const commands = app.group.actions.map((action) => command$1(action, (input) => Effect.scoped(Effect.flatMap(app.build, (handlers) => dispatch(app.group, action, handlers, before)(input)))));
|
|
22
|
+
const commands = app.group.actions.map((action) => command$1(action, (input) => local(app, action, options?.before, input)));
|
|
24
23
|
return Command.make(options?.name ?? app.group.name).pipe(Command.withSubcommands(commands));
|
|
25
24
|
};
|
|
26
25
|
//#endregion
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Action_d_exports, Codec } from "./Action.mjs";
|
|
2
2
|
import { Actions } from "./internal/actions.mjs";
|
|
3
|
+
import { ServedNames } from "./internal/implementation.mjs";
|
|
3
4
|
import { Options as Options$1 } from "./internal/cli.mjs";
|
|
4
5
|
import { ActionHttp_d_exports, Api, Http } from "./ActionHttp.mjs";
|
|
5
6
|
import { Effect } from "effect";
|
|
@@ -7,17 +8,21 @@ import { HttpClient } from "effect/unstable/http";
|
|
|
7
8
|
import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
|
|
8
9
|
import { Command } from "effect/unstable/cli";
|
|
9
10
|
//#region src/ActionCliClient.d.ts
|
|
11
|
+
/**
|
|
12
|
+
* What the host configures on Effect's native client: `baseUrl`, `transformClient`,
|
|
13
|
+
* `transformResponse`. The selectors and the client are the command's own.
|
|
14
|
+
*/
|
|
15
|
+
export type Connection = NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
|
|
10
16
|
/** Parsing, rendering and native HTTP-endpoint configuration for one remote action. */
|
|
11
17
|
export type Options<Output, ParsedParameters extends Command.Command.Config = never> = Options$1<Output, ParsedParameters> & {
|
|
12
|
-
|
|
13
|
-
readonly connection?: NonNullable<globalThis.Parameters<typeof HttpApiClient.make>[1]>;
|
|
18
|
+
readonly connection?: Connection;
|
|
14
19
|
};
|
|
15
20
|
/** Configuration for a remote aggregate group command. */
|
|
16
21
|
export interface GroupOptions {
|
|
17
22
|
/** Override the group command name. */
|
|
18
23
|
readonly name?: string;
|
|
19
24
|
/** Shared native HTTP-endpoint configuration for every remote action. */
|
|
20
|
-
readonly connection?:
|
|
25
|
+
readonly connection?: Connection;
|
|
21
26
|
}
|
|
22
27
|
type Group<Groups extends ReadonlyArray<Actions>, Name extends Groups[number]["name"]> = Extract<Groups[number], {
|
|
23
28
|
readonly name: Name;
|
|
@@ -25,9 +30,7 @@ type Group<Groups extends ReadonlyArray<Actions>, Name extends Groups[number]["n
|
|
|
25
30
|
type Selected<G extends Actions, Name extends G["actions"][number]["name"]> = Extract<G["actions"][number], {
|
|
26
31
|
readonly name: Name;
|
|
27
32
|
}>;
|
|
28
|
-
type HttpNames<G extends Actions> =
|
|
29
|
-
readonly http: true;
|
|
30
|
-
}>["name"];
|
|
33
|
+
type HttpNames<G extends Actions> = ServedNames<G, "http">;
|
|
31
34
|
type ApiGroups<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>> = Api<Groups[number], Errors[number]> extends HttpApi.HttpApi<"actions", infer ApiGroups> ? ApiGroups : never;
|
|
32
35
|
type Endpoint<Groups extends ReadonlyArray<Actions>, Errors extends ReadonlyArray<Codec>, GroupName extends Groups[number]["name"], ActionName extends HttpNames<Group<Groups, GroupName>>> = Extract<HttpApiGroup.EndpointsWithIdentifier<ApiGroups<Groups, Errors>, Extract<GroupName, HttpApiGroup.Identifier<ApiGroups<Groups, Errors>>>>, {
|
|
33
36
|
readonly identifier: ActionName;
|
package/dist/ActionCliClient.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectNamed } from "./internal/actions.mjs";
|
|
1
2
|
import { command as command$1 } from "./internal/cli.mjs";
|
|
2
3
|
import { Effect } from "effect";
|
|
3
4
|
import { HttpClient } from "effect/unstable/http";
|
|
@@ -12,14 +13,11 @@ import { Command } from "effect/unstable/cli";
|
|
|
12
13
|
const erasedApi = (api) => {
|
|
13
14
|
return api;
|
|
14
15
|
};
|
|
15
|
-
|
|
16
|
-
const group = groups.find((candidate) => candidate.name === name);
|
|
17
|
-
if (group === void 0) throw new Error(`Unknown HTTP group "${name}"`);
|
|
18
|
-
return group;
|
|
19
|
-
};
|
|
16
|
+
/** An action hidden from HTTP is unknown to this binding, exactly like a missing one. */
|
|
20
17
|
const selectAction = (group, name) => {
|
|
21
|
-
const
|
|
22
|
-
|
|
18
|
+
const what = `HTTP action "${group.name}.${name}"`;
|
|
19
|
+
const action = selectNamed(group.actions, name, what);
|
|
20
|
+
if (!action.http) throw new Error(`Unknown ${what}`);
|
|
23
21
|
return action;
|
|
24
22
|
};
|
|
25
23
|
/**
|
|
@@ -28,14 +26,14 @@ const selectAction = (group, name) => {
|
|
|
28
26
|
* keep dynamically supplied selector strings from reaching the native client.
|
|
29
27
|
*/
|
|
30
28
|
const command = (http, groupName, actionName, options) => {
|
|
31
|
-
const group =
|
|
29
|
+
const group = selectNamed(http.groups, groupName, `HTTP group "${groupName}"`);
|
|
32
30
|
const action = selectAction(group, actionName);
|
|
33
31
|
return command$1(action, (input) => Effect.flatMap(HttpClient.HttpClient, (httpClient) => {
|
|
34
32
|
const endpoint = HttpApiClient.endpoint(erasedApi(http.api), {
|
|
33
|
+
...options?.connection,
|
|
35
34
|
group: groupName,
|
|
36
35
|
endpoint: actionName,
|
|
37
|
-
httpClient
|
|
38
|
-
...options?.connection
|
|
36
|
+
httpClient
|
|
39
37
|
});
|
|
40
38
|
return Effect.flatMap(endpoint, (method) => {
|
|
41
39
|
return method({ payload: input });
|
|
@@ -44,7 +42,7 @@ const command = (http, groupName, actionName, options) => {
|
|
|
44
42
|
};
|
|
45
43
|
/** Project all HTTP-enabled actions retained by one group below its group namespace. */
|
|
46
44
|
const group = (http, groupName, options) => {
|
|
47
|
-
const actions =
|
|
45
|
+
const actions = selectNamed(http.groups, groupName, `HTTP group "${groupName}"`);
|
|
48
46
|
const commands = actions.actions.flatMap((action) => action.http ? [options?.connection === void 0 ? command(http, groupName, action.name) : command(http, groupName, action.name, { connection: options.connection })] : []);
|
|
49
47
|
return Command.make(options?.name ?? actions.name).pipe(Command.withSubcommands(commands));
|
|
50
48
|
};
|
package/dist/ActionGroup.d.mts
CHANGED
|
@@ -1,37 +1,39 @@
|
|
|
1
1
|
import { Action, Action_d_exports, Any as Any$1, Codec, Handler } from "./Action.mjs";
|
|
2
|
-
import { Actions, SchemaErrorPolicy } from "./internal/actions.mjs";
|
|
2
|
+
import { Actions, SchemaErrorAnswer, SchemaErrorPolicy } from "./internal/actions.mjs";
|
|
3
3
|
import { Implementation } from "./internal/implementation.mjs";
|
|
4
|
-
import { Effect, Scope } from "effect";
|
|
4
|
+
import { Effect, Scope, Types } from "effect";
|
|
5
5
|
declare namespace ActionGroup_d_exports {
|
|
6
|
-
export { Any, Contracts, Group, Implementation, Options, SchemaErrorPolicy, contracts, make };
|
|
6
|
+
export { Any, Contracts, Group, Implementation, Options, SchemaErrorAnswer, SchemaErrorPolicy, contracts, make };
|
|
7
7
|
}
|
|
8
8
|
type HandlersFrom<Actions extends ReadonlyArray<Any$1>> = { readonly [A in Actions[number] as A["name"]]: Handler<A, any>; };
|
|
9
9
|
/** Group-level errors join each action's own, so a shared set is declared once. */
|
|
10
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 Http, infer Mcp> ? Action<Name, Input, Output, readonly [...Own, ...Errors], Access, Http, Mcp> : never; };
|
|
11
11
|
/** What `make` needs to define a group. */
|
|
12
|
-
export interface Options<Name extends string, Errors extends ReadonlyArray<Codec>,
|
|
12
|
+
export interface Options<Name extends string, Errors extends ReadonlyArray<Codec>, Invalid extends Codec = never, Internal extends Codec = never> {
|
|
13
13
|
/** The `HttpApiGroup` identifier, and so the OpenAPI tag and operation-ID prefix. */
|
|
14
14
|
readonly name: Name;
|
|
15
15
|
/** Failures every action of the group may have, added to each action's own. */
|
|
16
16
|
readonly errors?: Errors;
|
|
17
|
-
/**
|
|
18
|
-
|
|
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>;
|
|
19
23
|
}
|
|
20
24
|
/** A named set of action contracts: what adapters serve and what `implement` binds. */
|
|
21
|
-
export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>,
|
|
25
|
+
export interface Group<Name extends string, Actions$1 extends ReadonlyArray<Any$1>, PolicyError extends Codec = Codec> extends Actions<Name, Actions$1, PolicyError> {
|
|
22
26
|
/** Bind every handler at once, resolving build-time services once per adapter layer. */
|
|
23
|
-
readonly implement: <H extends HandlersFrom<Actions$1>, EX = never, RX = never>(build: H | Effect.Effect<H, EX, RX>) => Implementation<Group<Name, Actions$1,
|
|
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>>>;
|
|
24
28
|
}
|
|
25
29
|
/** Any group, with its actions and error schemas erased. */
|
|
26
|
-
export type Any = Group<string, ReadonlyArray<Any$1>,
|
|
30
|
+
export type Any = Group<string, ReadonlyArray<Any$1>, Codec>;
|
|
27
31
|
/** Define a group. Invalid or duplicate action and MCP names fail here, at definition time. */
|
|
28
|
-
export declare function make<const Name extends string, const Actions extends ReadonlyArray<Any$1>, const Errors extends ReadonlyArray<Codec> = [],
|
|
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>;
|
|
29
33
|
/** `G extends unknown` distributes, so each group maps only its own actions. */
|
|
30
34
|
type PerGroup<G extends Actions> = G extends unknown ? { readonly [A in G["actions"][number] as `${G["name"]}.${A["name"]}`]: A; } : never;
|
|
31
|
-
/**
|
|
32
|
-
type
|
|
33
|
-
/** Every action of `Groups`, keyed `<group>.<action>`. */
|
|
34
|
-
export type Contracts<Groups extends ReadonlyArray<Actions>> = Merged<PerGroup<Groups[number]>>;
|
|
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]>>;
|
|
35
37
|
/**
|
|
36
38
|
* Project groups into one map of their action contracts, keyed `<group>.<action>`:
|
|
37
39
|
* the identity routes, tools and commands share. Deriving it from the groups
|
|
@@ -39,4 +41,4 @@ export type Contracts<Groups extends ReadonlyArray<Actions>> = Merged<PerGroup<G
|
|
|
39
41
|
*/
|
|
40
42
|
export declare function contracts<const Groups extends ReadonlyArray<Actions>>(...groups: Groups): Contracts<Groups>;
|
|
41
43
|
//#endregion
|
|
42
|
-
export { ActionGroup_d_exports, type Implementation, type SchemaErrorPolicy };
|
|
44
|
+
export { ActionGroup_d_exports, type Implementation, type SchemaErrorAnswer, type SchemaErrorPolicy };
|
package/dist/ActionGroup.mjs
CHANGED
|
@@ -2,6 +2,17 @@ import { assertDistinct, assertName } from "./internal/actions.mjs";
|
|
|
2
2
|
import { Implementation } from "./internal/implementation.mjs";
|
|
3
3
|
import { Effect } from "effect";
|
|
4
4
|
//#region src/ActionGroup.ts
|
|
5
|
+
/**
|
|
6
|
+
* The types require every handler, but a record built in plain JavaScript or through a
|
|
7
|
+
* cast may still lack one, or hold `undefined` under its key. It fails before anything is served rather than on the
|
|
8
|
+
* first request for the missing action.
|
|
9
|
+
*/
|
|
10
|
+
const assertHandlers = (group, handlers) => {
|
|
11
|
+
const bound = new Map(Object.entries(handlers));
|
|
12
|
+
const missing = group.actions.filter((action) => bound.get(action.name) === void 0);
|
|
13
|
+
if (missing.length > 0) throw new Error(`Missing handlers for group "${group.name}": ${missing.map((action) => action.name).join(", ")}`);
|
|
14
|
+
return handlers;
|
|
15
|
+
};
|
|
5
16
|
function make(options, ...declared) {
|
|
6
17
|
const { name } = options;
|
|
7
18
|
assertName("action group name", name);
|
|
@@ -16,7 +27,7 @@ function make(options, ...declared) {
|
|
|
16
27
|
actions,
|
|
17
28
|
schemaError: options.schemaError,
|
|
18
29
|
implement: (build) => {
|
|
19
|
-
const built = Effect.isEffect(build) ? build : Effect.succeed(build);
|
|
30
|
+
const built = Effect.isEffect(build) ? Effect.map(build, (handlers) => assertHandlers(group, handlers)) : Effect.succeed(assertHandlers(group, build));
|
|
20
31
|
return Implementation.make(group, built);
|
|
21
32
|
}
|
|
22
33
|
};
|
package/dist/ActionHttp.d.mts
CHANGED
|
@@ -24,10 +24,9 @@ export interface Options<Errors extends ReadonlyArray<Codec> = []> {
|
|
|
24
24
|
/** What `Http.layer` binds around the implementations it serves. */
|
|
25
25
|
export interface LayerOptions<Errors extends ReadonlyArray<Codec>, R> {
|
|
26
26
|
/**
|
|
27
|
-
* Runs once
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* is encoded exactly like a declared error. Its services are request-time
|
|
27
|
+
* Runs once after successful payload decoding, before the selected handler,
|
|
28
|
+
* with its action contract. It fails with the binding's own `errors`, encoded
|
|
29
|
+
* exactly like a declared error. Its services are request-time
|
|
31
30
|
* requirements, like a handler's.
|
|
32
31
|
*/
|
|
33
32
|
readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
|
|
@@ -48,7 +47,13 @@ export interface Http<Groups extends ReadonlyArray<Actions>, Errors extends Read
|
|
|
48
47
|
* around every request they answer. Build failures, build services and request
|
|
49
48
|
* services are unions over precisely those apps and that hook.
|
|
50
49
|
*/
|
|
51
|
-
readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>, RB = never>(
|
|
50
|
+
readonly layer: <const Apps extends ReadonlyArray<AnyImplementation<Groups[number]>>, RB = never>(apps: readonly [...Apps], options?: LayerOptions<Errors, RB>) => Layer.Layer<never, BuildError<Apps[number], "http">, BuildContext<Apps[number], "http"> | HttpRouter.HttpRouter | HttpRouter.Request.From<"Requires", RequestContext<Apps[number], "http"> | RB> | Etag.Generator | FileSystem | HttpPlatform.HttpPlatform | Path>;
|
|
51
|
+
/**
|
|
52
|
+
* Serve the OpenAPI document of `api` with `GET path`, by default
|
|
53
|
+
* `<apiPath>/openapi.json`. It is a plain route: middleware provided to this layer
|
|
54
|
+
* covers it, and none is applied otherwise.
|
|
55
|
+
*/
|
|
56
|
+
readonly openApi: (path?: HttpRouter.PathInput) => Layer.Layer<never, never, HttpRouter.HttpRouter>;
|
|
52
57
|
}
|
|
53
58
|
/**
|
|
54
59
|
* Bind the contract-level configuration once. Every route is `POST
|
package/dist/ActionHttp.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { assertDistinct, projectedErrors } from "./internal/actions.mjs";
|
|
1
|
+
import { answerSchemaError, assertDistinct, policyErrors, projectedErrors } from "./internal/actions.mjs";
|
|
2
2
|
import { dispatch } from "./internal/implementation.mjs";
|
|
3
3
|
import { Effect, Layer } from "effect";
|
|
4
|
-
import {
|
|
4
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
5
5
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi";
|
|
6
6
|
//#region src/ActionHttp.ts
|
|
7
7
|
const httpActions = (group) => group.actions.filter((action) => action.http);
|
|
@@ -12,42 +12,24 @@ const endpoint = (options, group, action) => HttpApiEndpoint.post(action.name, `
|
|
|
12
12
|
}).annotate(OpenApi.Description, action.description);
|
|
13
13
|
/** The native middleware declares the policy's errors on every endpoint it wraps. */
|
|
14
14
|
const schemaErrorMiddleware = (name, policy) => {
|
|
15
|
-
class SchemaErrors extends HttpApiMiddleware.Service()(`effect-actions/http/SchemaErrors/${name}`, { error: policy
|
|
15
|
+
class SchemaErrors extends HttpApiMiddleware.Service()(`effect-actions/http/SchemaErrors/${name}`, { error: policyErrors(policy) }) {}
|
|
16
16
|
return {
|
|
17
17
|
SchemaErrors,
|
|
18
|
-
layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(policy
|
|
19
|
-
};
|
|
20
|
-
};
|
|
21
|
-
/** The hook of one group, applied to every one of its endpoints. */
|
|
22
|
-
const hookMiddleware = (name) => {
|
|
23
|
-
class Admission extends HttpApiMiddleware.Service()(`effect-actions/http/Admission/${name}`) {}
|
|
24
|
-
const implementation = (group, before) => {
|
|
25
|
-
if (before === void 0) return (httpEffect) => httpEffect;
|
|
26
|
-
const actions = new Map(httpActions(group).map((action) => [action.name, action]));
|
|
27
|
-
return (httpEffect, { endpoint }) => {
|
|
28
|
-
const action = actions.get(endpoint.identifier);
|
|
29
|
-
if (action === void 0) throw new Error(`Missing action: ${endpoint.identifier}`);
|
|
30
|
-
return Effect.flatMap(before(action), () => httpEffect);
|
|
31
|
-
};
|
|
32
|
-
};
|
|
33
|
-
return {
|
|
34
|
-
Admission,
|
|
35
|
-
layer: (group, before) => Layer.succeed(Admission, implementation(group, before))
|
|
18
|
+
layer: HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrors, (failure) => Effect.fail(answerSchemaError(policy, failure)))
|
|
36
19
|
};
|
|
37
20
|
};
|
|
38
21
|
function bind(options, group) {
|
|
39
22
|
const [first, ...rest] = httpActions(group).map((action) => endpoint(options, group, action));
|
|
40
23
|
if (first === void 0) return void 0;
|
|
41
24
|
const bound = HttpApiGroup.make(group.name).add(first, ...rest);
|
|
42
|
-
const hook = hookMiddleware(group.name);
|
|
43
25
|
if (group.schemaError === void 0) return {
|
|
44
|
-
native: bound
|
|
45
|
-
middleware:
|
|
26
|
+
native: bound,
|
|
27
|
+
middleware: Layer.empty
|
|
46
28
|
};
|
|
47
29
|
const policy = schemaErrorMiddleware(group.name, group.schemaError);
|
|
48
30
|
return {
|
|
49
|
-
native: bound.middleware(policy.SchemaErrors)
|
|
50
|
-
middleware:
|
|
31
|
+
native: bound.middleware(policy.SchemaErrors),
|
|
32
|
+
middleware: policy.layer
|
|
51
33
|
};
|
|
52
34
|
}
|
|
53
35
|
function apiOf(groups) {
|
|
@@ -66,22 +48,20 @@ const erasedHandlers = (handlers) => {
|
|
|
66
48
|
return handlers;
|
|
67
49
|
};
|
|
68
50
|
/** Register one group's handlers. Handler erasure is limited to native dynamic endpoint registration. */
|
|
69
|
-
const groupHandlers = (api, app, middleware) => {
|
|
70
|
-
const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) =>
|
|
51
|
+
const groupHandlers = (api, app, middleware, before) => {
|
|
52
|
+
const entries = (record) => Object.fromEntries(httpActions(app.group).map((action) => {
|
|
53
|
+
const run = dispatch(app.group, action, record, before);
|
|
54
|
+
return [action.name, (request) => run(request.payload)];
|
|
55
|
+
}));
|
|
71
56
|
return Layer.unwrap(Effect.map(app.build, (record) => HttpApiBuilder.group(api, app.group.name, (builder) => builder.handleAll(entries(erasedHandlers(record)))))).pipe(Layer.provide(middleware));
|
|
72
57
|
};
|
|
73
|
-
/**
|
|
74
|
-
* A refused or failed request is never stored by a shared cache. Successful
|
|
75
|
-
* responses keep whatever the host sets, since only the host knows what is public.
|
|
76
|
-
*/
|
|
77
|
-
const noStore = HttpRouter.middleware((httpEffect) => HttpEffect.withPreResponseHandler(httpEffect, (_request, response) => Effect.succeed(response.status < 400 ? response : HttpServerResponse.setHeader(response, "cache-control", "no-store"))));
|
|
78
58
|
function make(options, ...groups) {
|
|
79
59
|
assertDistinct("action group", groups.map((group) => group.name));
|
|
80
60
|
const bound = new Map(groups.flatMap((group) => {
|
|
81
61
|
const binding = bind(options, group);
|
|
82
62
|
return binding === void 0 ? [] : [[group, binding]];
|
|
83
63
|
}));
|
|
84
|
-
const layer = (
|
|
64
|
+
const layer = (apps, layerOptions = {}) => {
|
|
85
65
|
assertDistinct("implementation group", apps.map((app) => app.group.name));
|
|
86
66
|
for (const app of apps) if (!groups.includes(app.group)) throw new Error(`Implementation of group "${app.group.name}" is not served by this adapter`);
|
|
87
67
|
const served = apps.flatMap((app) => {
|
|
@@ -92,13 +72,16 @@ function make(options, ...groups) {
|
|
|
92
72
|
}];
|
|
93
73
|
});
|
|
94
74
|
const api = apiOf(served.map(({ native }) => native));
|
|
95
|
-
const
|
|
96
|
-
return
|
|
75
|
+
const [first, ...rest] = served.map(({ app, middleware }) => groupHandlers(api, app, middleware, layerOptions.before));
|
|
76
|
+
return first === void 0 ? HttpApiBuilder.layer(api) : HttpApiBuilder.layer(api).pipe(Layer.provide([first, ...rest]));
|
|
97
77
|
};
|
|
78
|
+
const api = apiOf([...bound.values()].map(({ native }) => native));
|
|
79
|
+
const openApi = (path = `${options.apiPath}/openapi.json`) => HttpRouter.add("GET", path, HttpServerResponse.jsonUnsafe(OpenApi.fromApi(api)));
|
|
98
80
|
return {
|
|
99
81
|
groups,
|
|
100
|
-
api
|
|
101
|
-
layer
|
|
82
|
+
api,
|
|
83
|
+
layer,
|
|
84
|
+
openApi
|
|
102
85
|
};
|
|
103
86
|
}
|
|
104
87
|
//#endregion
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Action_d_exports, Any, Codec } from "./Action.mjs";
|
|
2
|
+
import { Actions } from "./internal/actions.mjs";
|
|
3
|
+
import { ServedNames } from "./internal/implementation.mjs";
|
|
4
|
+
import { ActionHttp_d_exports, Http } from "./ActionHttp.mjs";
|
|
5
|
+
//#region src/ActionHttpClient.d.ts
|
|
6
|
+
/** Where and how a Promise client sends its requests. */
|
|
7
|
+
export interface Options {
|
|
8
|
+
/**
|
|
9
|
+
* What the binding's routes are resolved against, such as `https://api.example.com`.
|
|
10
|
+
* Omitted, routes stay relative, which a browser resolves against the page's origin.
|
|
11
|
+
*/
|
|
12
|
+
readonly baseUrl?: string | URL;
|
|
13
|
+
/** Sent as `Authorization: Bearer <token>` with every call. */
|
|
14
|
+
readonly token?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The transport. Defaults to the global `fetch`, looked up per call. Wrap it to add
|
|
17
|
+
* headers or to observe responses, such as a proxy's 401.
|
|
18
|
+
*/
|
|
19
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
20
|
+
}
|
|
21
|
+
/** The HTTP-served actions of `G`: an action with a literal `http: false` has no method. */
|
|
22
|
+
type HttpAction<G extends Actions> = Extract<G["actions"][number], {
|
|
23
|
+
readonly name: ServedNames<G, "http">;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* One action as a Promise of its decoded success. An action whose input may be empty,
|
|
27
|
+
* such as one declared without `input`, may be called without an argument.
|
|
28
|
+
*/
|
|
29
|
+
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"]>;
|
|
30
|
+
/** Every HTTP-served action of the binding, as `client.<group>.<action>(input)`. */
|
|
31
|
+
export type Client<Groups extends ReadonlyArray<Actions>> = { readonly [G in Groups[number] as [HttpAction<G>] extends [never] ? never : G["name"]]: { readonly [A in HttpAction<G> as A["name"]]: Method<A>; }; };
|
|
32
|
+
/**
|
|
33
|
+
* A Promise client for an HTTP binding, for code that does not run Effects, such as a
|
|
34
|
+
* browser page. It is Effect's native `HttpApiClient` over `fetch`, built once, and
|
|
35
|
+
* each call runs one request: nothing is retried.
|
|
36
|
+
*
|
|
37
|
+
* A call resolves with the action's decoded success. It rejects with what the native
|
|
38
|
+
* client fails with: a declared error value (the action's, its group's schema-error
|
|
39
|
+
* policy's, or the binding's surface `errors`), a native `HttpClientError` when the
|
|
40
|
+
* server could not be reached or answered with a status or body the contract does not
|
|
41
|
+
* declare, or a `SchemaError` when the input does not encode or the success does not
|
|
42
|
+
* decode.
|
|
43
|
+
*/
|
|
44
|
+
export declare function promise<const Groups extends ReadonlyArray<Actions>, const Errors extends ReadonlyArray<Codec>>(http: Http<Groups, Errors>, options?: Options): Client<Groups>;
|
|
45
|
+
//#endregion
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
4
|
+
//#region src/ActionHttpClient.ts
|
|
5
|
+
function promise(http, { baseUrl, token, fetch } = {}) {
|
|
6
|
+
const send = fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
7
|
+
const native = Effect.runSync(HttpApiClient.make(http.api, {
|
|
8
|
+
baseUrl,
|
|
9
|
+
transformClient: token === void 0 ? void 0 : HttpClient.mapRequest(HttpClientRequest.bearerToken(token))
|
|
10
|
+
}).pipe(Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.Fetch, send)));
|
|
11
|
+
return Object.fromEntries(http.groups.flatMap((group) => {
|
|
12
|
+
const endpoints = native[group.name];
|
|
13
|
+
if (endpoints === void 0) return [];
|
|
14
|
+
const methods = group.actions.flatMap((action) => {
|
|
15
|
+
const endpoint = endpoints[action.name];
|
|
16
|
+
if (endpoint === void 0) return [];
|
|
17
|
+
const method = (input) => Effect.runPromise(endpoint({ payload: input ?? {} }));
|
|
18
|
+
return [[action.name, method]];
|
|
19
|
+
});
|
|
20
|
+
return [[group.name, Object.fromEntries(methods)]];
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { promise };
|