@gjermundgaraba/effect-actions 0.9.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.
- package/README.md +57 -50
- package/dist/Action.d.mts +285 -79
- package/dist/Action.mjs +124 -23
- package/dist/ActionCli.d.mts +102 -31
- package/dist/ActionCli.mjs +114 -16
- package/dist/ActionHttp.d.mts +170 -51
- package/dist/ActionHttp.mjs +130 -79
- package/dist/ActionMcp.d.mts +71 -21
- package/dist/ActionMcp.mjs +108 -81
- package/dist/ActionToolkit.d.mts +56 -27
- package/dist/ActionToolkit.mjs +16 -5
- package/dist/Authentication.d.mts +129 -33
- package/dist/Authentication.mjs +231 -38
- package/dist/Testing.d.mts +82 -57
- package/dist/Testing.mjs +89 -79
- package/dist/internal/actions.d.mts +5 -25
- package/dist/internal/actions.mjs +75 -27
- package/dist/internal/authentication.d.mts +102 -0
- package/dist/internal/authentication.mjs +11 -0
- package/dist/internal/call.d.mts +12 -0
- package/dist/internal/call.mjs +12 -0
- package/dist/internal/cli.d.mts +36 -22
- package/dist/internal/cli.mjs +311 -33
- package/dist/internal/client.d.mts +43 -0
- package/dist/internal/client.mjs +25 -0
- package/dist/internal/console.mjs +77 -0
- package/dist/internal/declared.d.mts +13 -0
- package/dist/internal/declared.mjs +34 -0
- package/dist/internal/errors.d.mts +77 -0
- package/dist/internal/errors.mjs +79 -0
- package/dist/internal/implementation.d.mts +171 -41
- package/dist/internal/implementation.mjs +147 -29
- package/dist/internal/mcp.d.mts +11 -0
- package/dist/internal/mcp.mjs +52 -0
- package/dist/internal/memory.d.mts +6 -0
- package/dist/internal/memory.mjs +38 -0
- package/dist/internal/refusal.mjs +67 -0
- package/dist/internal/schema-errors.mjs +20 -0
- package/dist/internal/tools.mjs +27 -57
- package/docs/Action.md +401 -73
- package/docs/ActionCli.md +280 -51
- package/docs/ActionHttp.md +266 -82
- package/docs/ActionMcp.md +149 -104
- package/docs/ActionToolkit.md +97 -35
- package/docs/Authentication.md +369 -62
- package/docs/CONTEXT.md +23 -36
- package/docs/README.md +29 -73
- package/docs/Testing.md +154 -77
- package/docs/guarantees.md +81 -43
- package/docs/setup.md +90 -27
- package/package.json +7 -15
- package/dist/ActionCatalog.d.mts +0 -30
- package/dist/ActionCatalog.mjs +0 -39
- package/dist/ActionCliClient.d.mts +0 -48
- package/dist/ActionCliClient.mjs +0 -43
- package/dist/ActionGroup.d.mts +0 -44
- package/dist/ActionGroup.mjs +0 -35
- package/dist/ActionHttpClient.d.mts +0 -33
- package/dist/ActionHttpClient.mjs +0 -16
- package/dist/TestingClient.d.mts +0 -17
- package/dist/TestingClient.mjs +0 -22
- package/dist/internal/fetchClient.d.mts +0 -6
- package/dist/internal/fetchClient.mjs +0 -8
- package/dist/internal/tools.d.mts +0 -22
- package/docs/ActionCatalog.md +0 -54
- package/docs/ActionCliClient.md +0 -63
- package/docs/ActionGroup.md +0 -128
- package/docs/ActionHttpClient.md +0 -80
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Effect, Predicate, Schema, SchemaIssue } from "effect";
|
|
2
|
+
//#region src/internal/errors.ts
|
|
3
|
+
/** A message field that defaults, so `new Forbidden()` needs no argument. */
|
|
4
|
+
const message = (fallback) => Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed(fallback)));
|
|
5
|
+
/** Each built-in error's HTTP status, by tag: its annotation, and what a refusal answers. */
|
|
6
|
+
const statuses = {
|
|
7
|
+
InvalidInput: 400,
|
|
8
|
+
Unauthenticated: 401,
|
|
9
|
+
Forbidden: 403
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* One thing wrong with an input: where, as the keys and indexes leading to it from the input's
|
|
13
|
+
* root, none for the input itself, and what the schema expects there.
|
|
14
|
+
*/
|
|
15
|
+
const Issue = Schema.Struct({
|
|
16
|
+
path: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
|
|
17
|
+
message: Schema.String
|
|
18
|
+
});
|
|
19
|
+
const standardIssues = SchemaIssue.makeFormatterStandardSchemaV1();
|
|
20
|
+
/**
|
|
21
|
+
* The request's input does not decode against the action's input. HTTP answers every such
|
|
22
|
+
* request with it, its message the schema's own description of what is wrong, and `issues`
|
|
23
|
+
* the same description an issue at a time, for a caller that points at a field. A handler
|
|
24
|
+
* failing with it for input that decodes but cannot be served may name its own.
|
|
25
|
+
*/
|
|
26
|
+
var InvalidInput = class InvalidInput extends Schema.TaggedError()("InvalidInput", {
|
|
27
|
+
message: message("The input does not match the action's input."),
|
|
28
|
+
issues: Schema.Array(Issue).pipe(Schema.withConstructorDefault(Effect.succeed([])))
|
|
29
|
+
}, { httpApiStatus: statuses.InvalidInput }) {
|
|
30
|
+
/**
|
|
31
|
+
* The `InvalidInput` for input that did not decode, as every surface answers it: the
|
|
32
|
+
* schema's message, and each of its issues by path, for code that decodes input of its own,
|
|
33
|
+
* such as a header or a route parameter. Neither carries a value of the input: decoding
|
|
34
|
+
* runs without `reportInput`, so no issue holds one to format. A symbol key is sent as its
|
|
35
|
+
* string form, `Symbol(name)`, as JSON has no symbols, which tells it from a string key of
|
|
36
|
+
* that name.
|
|
37
|
+
*/
|
|
38
|
+
static fromSchemaError = ({ issue, message }) => new InvalidInput({
|
|
39
|
+
message,
|
|
40
|
+
issues: standardIssues(issue).issues.map((found) => ({
|
|
41
|
+
path: (found.path ?? []).map((segment) => {
|
|
42
|
+
const key = Predicate.isPropertyKey(segment) ? segment : segment.key;
|
|
43
|
+
return Predicate.isSymbol(key) ? String(key) : key;
|
|
44
|
+
}),
|
|
45
|
+
message: found.message
|
|
46
|
+
}))
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
/** The caller is not authenticated: authentication or an implementation's `authorize` refuses. */
|
|
50
|
+
var Unauthenticated = class extends Schema.TaggedError()("Unauthenticated", { message: message("Authentication is required.") }, { httpApiStatus: statuses.Unauthenticated }) {};
|
|
51
|
+
/** An OAuth scope token (RFC 6749 §3.3): printable ASCII but space, `"` and `\`. */
|
|
52
|
+
const scopeToken = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
53
|
+
const ScopeToken = Schema.String.check(Schema.isPattern(scopeToken));
|
|
54
|
+
/**
|
|
55
|
+
* The caller may not run this action: an implementation's `authorize` refuses. `scopes`,
|
|
56
|
+
* when given, are every OAuth scope the call needs: a refusal naming them is answered with
|
|
57
|
+
* the `insufficient_scope` challenge an OAuth client re-authorizes on.
|
|
58
|
+
*/
|
|
59
|
+
var Forbidden = class extends Schema.TaggedError()("Forbidden", {
|
|
60
|
+
message: message("Not allowed."),
|
|
61
|
+
scopes: Schema.optionalKey(Schema.NonEmptyArray(ScopeToken))
|
|
62
|
+
}, { httpApiStatus: statuses.Forbidden }) {};
|
|
63
|
+
/** The refusals, as schemas. */
|
|
64
|
+
const refusals = [Unauthenticated, Forbidden];
|
|
65
|
+
/**
|
|
66
|
+
* What authentication or an implementation's `authorize` refuses a caller with, instead of
|
|
67
|
+
* running a handler. An error an action declares, such as a rate limit, is not a refusal. As a
|
|
68
|
+
* schema, it tells a refusal apart: `Schema.is(Action.Refusal)(error)`.
|
|
69
|
+
*/
|
|
70
|
+
const Refusal = Schema.Union(refusals);
|
|
71
|
+
/**
|
|
72
|
+
* The failures every endpoint and tool declares beyond its action's own, and any handler may
|
|
73
|
+
* fail with: bad input, and every refusal. No action or binding declares them itself.
|
|
74
|
+
*/
|
|
75
|
+
const builtIns = [InvalidInput, ...refusals];
|
|
76
|
+
/** The built-in failures as one schema: `Schema.is(Action.BuiltIn)(error)`. */
|
|
77
|
+
const BuiltIn = Schema.Union(builtIns);
|
|
78
|
+
//#endregion
|
|
79
|
+
export { BuiltIn, Forbidden, InvalidInput, Refusal, Unauthenticated, builtIns, scopeToken, statuses };
|
|
@@ -1,49 +1,179 @@
|
|
|
1
|
+
import { Anyone } from "./actions.mjs";
|
|
2
|
+
import { Refusal } from "./errors.mjs";
|
|
1
3
|
import { Action_d_exports, Any } from "../Action.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { Effect, Scope } from "effect";
|
|
4
|
+
import { Context, Effect, Layer, Scope } from "effect";
|
|
4
5
|
//#region src/internal/implementation.d.ts
|
|
5
|
-
/** Decoded values at the
|
|
6
|
+
/** Decoded values at the dispatch boundary every surface shares. */
|
|
6
7
|
export type ErasedValue = Any["input"]["Type"];
|
|
7
|
-
type ErasedHandler<R> = {
|
|
8
|
+
export type ErasedHandler<R> = {
|
|
8
9
|
handle(input: ErasedValue): Effect.Effect<ErasedValue, ErasedValue, R>;
|
|
9
10
|
}["handle"];
|
|
10
|
-
/**
|
|
11
|
-
export type
|
|
12
|
-
/**
|
|
13
|
-
export type
|
|
14
|
-
/**
|
|
15
|
-
export type
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
11
|
+
/** Authorization runs only for protected actions, after authentication and before the handler. */
|
|
12
|
+
export type Authorize<A extends Any, R = never> = (action: A) => Effect.Effect<void, Refusal, R>;
|
|
13
|
+
/** An implementation's authorizer, erased. */
|
|
14
|
+
export type ErasedAuthorize = (action: Any) => Effect.Effect<void, unknown, unknown>;
|
|
15
|
+
/** Each action of an implementation with its handler, as its builder made them. */
|
|
16
|
+
export type Bound = ReadonlyArray<readonly [Any, ErasedHandler<unknown>]>;
|
|
17
|
+
/**
|
|
18
|
+
* What a layer builds once per layer graph, under a key private to it: an implementation's
|
|
19
|
+
* handlers, or its authorizer. Effect memoizes a layer by reference within one layer graph, so
|
|
20
|
+
* everything in the graph holding this one shares one run.
|
|
21
|
+
*/
|
|
22
|
+
export interface Memoized<S, E, R> {
|
|
23
|
+
readonly key: Context.Key<S, S>;
|
|
24
|
+
readonly layer: Layer.Layer<S, E, R>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Actions bound to their handlers and their authorizer: everything one `Action.implement` call
|
|
28
|
+
* produced, one builder serving every surface.
|
|
29
|
+
* The private fields make this class nominal: a structurally similar object, including one made by
|
|
30
|
+
* spreading an implementation, is not an implementation. Its type parameters may be written
|
|
31
|
+
* out, as a package emitting declarations prints them: `A` its actions, `RequestServices` each
|
|
32
|
+
* action name's handler's per-request requirements, `EX` and `RX` the failures and services of
|
|
33
|
+
* its handlers' builder, `RA` what its authorizer reads per request, and `EAX` and `RAX` the
|
|
34
|
+
* failures and services of the authorizer's builder. Public-only selections do not acquire
|
|
35
|
+
* authorization. A plain authorization callback builds nothing.
|
|
36
|
+
*/
|
|
37
|
+
export declare class Implementation<A extends Any, RequestServices extends {
|
|
38
|
+
readonly [name: string]: unknown;
|
|
39
|
+
}, EX, RX, RA = never, EAX = never, RAX = never> {
|
|
23
40
|
#private;
|
|
24
|
-
/** The
|
|
25
|
-
readonly
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
/** The contracts this implementation answers. */
|
|
42
|
+
readonly actions: ReadonlyArray<A>;
|
|
43
|
+
/** Type-only: each action's handler's per-request requirements. */
|
|
44
|
+
readonly "~request": RequestServices;
|
|
45
|
+
/** Type-only: what building its handlers fails with. */
|
|
46
|
+
readonly "~buildError": EX;
|
|
47
|
+
/** Type-only: what building its handlers needs. */
|
|
48
|
+
readonly "~buildServices": RX;
|
|
49
|
+
/** Type-only: what its authorizer reads per request. */
|
|
50
|
+
readonly "~authorizeRequest": RA;
|
|
51
|
+
/** Type-only: what building its authorizer fails with. */
|
|
52
|
+
readonly "~authorizeBuildError": EAX;
|
|
53
|
+
/** Type-only: what building its authorizer needs. */
|
|
54
|
+
readonly "~authorizeBuildServices": RAX;
|
|
55
|
+
constructor(
|
|
56
|
+
/** The contracts this implementation answers. */
|
|
57
|
+
actions: ReadonlyArray<A>,
|
|
58
|
+
/** Builds each action paired with its handler: its own builder, or the one it shares. */
|
|
59
|
+
handlers: Memoized<Bound, EX, RX>,
|
|
60
|
+
/** Builds its authorizer. */
|
|
61
|
+
authorizer: Memoized<ErasedAuthorize, EAX, RAX>);
|
|
62
|
+
/** Select contracts while retaining their handlers, authorization and shared builder. */
|
|
63
|
+
static share(actions: ReadonlyArray<Any>, app: AnyImplementation): AnyImplementation;
|
|
64
|
+
/**
|
|
65
|
+
* The key of what runs a call of `app`'s actions: its handlers' memoized layer's, which each
|
|
66
|
+
* `implement` makes, so it is unique to the implementation. Every selection retains it.
|
|
67
|
+
*/
|
|
68
|
+
static runKey(app: AnyImplementation): string;
|
|
69
|
+
/**
|
|
70
|
+
* The builders of `app`, its handlers' and its authorizer's, as a layer. Effect memoizes each
|
|
71
|
+
* by reference within one layer graph, so every surface serving `app` there, and every
|
|
72
|
+
* implementation sharing its builder, shares one run. Static, so it stays off the
|
|
73
|
+
* public instance type.
|
|
74
|
+
*/
|
|
75
|
+
static layerOf(app: AnyImplementation): Layer.Layer<never, unknown, unknown>;
|
|
76
|
+
/**
|
|
77
|
+
* The actions of `app` with their handlers, behind its authorizer, from the context
|
|
78
|
+
* `layerOf(app)` provides.
|
|
79
|
+
*/
|
|
80
|
+
static boundOf(app: AnyImplementation): Effect.Effect<Bound, never, unknown>;
|
|
81
|
+
/** `app`, if this copy of the module made it; another installed copy's cannot be read. */
|
|
82
|
+
private static own;
|
|
83
|
+
}
|
|
84
|
+
/** Any implementation, with its actions and channels erased. */
|
|
85
|
+
export type AnyImplementation<A extends Any = Any> = Implementation<A, {
|
|
86
|
+
readonly [name: string]: unknown;
|
|
87
|
+
}, unknown, unknown, unknown, unknown, unknown>;
|
|
88
|
+
/** What a surface serves: one implementation, or a list of them. */
|
|
89
|
+
export type Served = AnyImplementation | ReadonlyArray<AnyImplementation>;
|
|
90
|
+
/** The implementations `S` stands for. */
|
|
91
|
+
export type Member<S> = S extends ReadonlyArray<infer App> ? App : S;
|
|
92
|
+
/** Options that may list actions of `A`, the shape every selecting surface's options share. */
|
|
93
|
+
export interface Selection<A extends Any> {
|
|
94
|
+
readonly actions?: ReadonlyArray<A> | undefined;
|
|
30
95
|
}
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export type
|
|
47
|
-
/**
|
|
48
|
-
|
|
96
|
+
/**
|
|
97
|
+
* The actions options `O` select, of `All`: those its `actions` lists when it always lists
|
|
98
|
+
* them, and every one otherwise. Options whose `actions` may be absent, being optional or
|
|
99
|
+
* a union with options lacking it, select every action at run time, so they narrow nothing.
|
|
100
|
+
*/
|
|
101
|
+
export type Selected<O, All extends Any> = O extends {
|
|
102
|
+
readonly actions: ReadonlyArray<infer A extends Any>;
|
|
103
|
+
} ? A : All;
|
|
104
|
+
/** The actions options `O` select of the implementations `Apps` stand for. */
|
|
105
|
+
export type SelectedOf<O, Apps extends Served> = Selected<O, ActionOf<Member<Apps>>>;
|
|
106
|
+
/**
|
|
107
|
+
* The actions a client of options `O` offers methods for, of `All`: those `actions` may
|
|
108
|
+
* list, wherever some member of `O` may list them, as each is present whether the list is
|
|
109
|
+
* given or not; every one where no member lists any.
|
|
110
|
+
*/
|
|
111
|
+
export type Offered<O, All extends Any> = [Listing<O>] extends [never] ? All : Extract<All, Listing<O> extends ReadonlyArray<infer A extends Any> ? A : never>;
|
|
112
|
+
/** The lists of actions the members of `O` may give; none where no member gives one. */
|
|
113
|
+
type Listing<O> = O extends unknown ? NonNullable<O["actions" & keyof O]> : never;
|
|
114
|
+
/**
|
|
115
|
+
* No key of any member of `O` beyond those of `Keys`, so a misspelled option is refused
|
|
116
|
+
* rather than ignored: options inferred whole, as a type parameter, skip TypeScript's own
|
|
117
|
+
* check, and `keyof` a union holds only the keys every member has.
|
|
118
|
+
*/
|
|
119
|
+
export type Known<O, Keys> = { readonly [K in Exclude<O extends unknown ? keyof O : never, keyof Keys>]?: never; };
|
|
120
|
+
/**
|
|
121
|
+
* The parameters of an optional options argument of type `O`: required where `O` names what
|
|
122
|
+
* only a given argument holds, such as listed actions, so explicit type arguments cannot
|
|
123
|
+
* narrow what an omitted argument leaves whole.
|
|
124
|
+
*/
|
|
125
|
+
export type OptionalUnless<O, Given> = {} extends O ? [options?: Given] : [options: Given];
|
|
126
|
+
/** The actions of the implementations a surface serves. */
|
|
127
|
+
export type ActionOf<App> = App extends {
|
|
128
|
+
readonly actions: ReadonlyArray<infer A>;
|
|
129
|
+
} ? A : never;
|
|
130
|
+
/** Whether `A` may be one of `Listed`: either is assignable to the other. */
|
|
131
|
+
type Matches<A extends Any, Listed extends Any> = Listed extends Any ? [A] extends [Listed] ? true : [Listed] extends [A] ? true : never : never;
|
|
132
|
+
/**
|
|
133
|
+
* The actions of `App` a surface given the actions `Listed` may serve, as `select` selects
|
|
134
|
+
* them by identity: an action whose type either is assignable to, or is assigned by, one
|
|
135
|
+
* listed. So an implementation typed wider than its contract, or a list typed wider than its
|
|
136
|
+
* actions, an erased one included, still counts, and another contract of a listed name, which
|
|
137
|
+
* the surface leaves out beside an action it serves, does not.
|
|
138
|
+
*/
|
|
139
|
+
export type Serving<App, Listed extends Any> = ActionOf<App> extends (infer A extends Any) ? A extends unknown ? [A] extends [Listed] ? A : string extends A["name"] ? A : [Extract<A["name"], Listed["name"]>] extends [never] ? never : [Matches<A, Listed>] extends [never] ? never : A : never : never;
|
|
140
|
+
/**
|
|
141
|
+
* What each implementation among `App` owes per request where a surface serving the actions
|
|
142
|
+
* `Listed` serves it: its authorizer's services, and the handlers' of the actions it serves.
|
|
143
|
+
*/
|
|
144
|
+
export type ServedRequest<App, Listed extends Any> = App extends unknown ? RequestOf<App, Serving<App, Listed>> : never;
|
|
145
|
+
/** The implementations among `App` holding an action of `Listed`: those a surface builds. */
|
|
146
|
+
export type Holding<App, Listed extends Any> = App extends unknown ? [Serving<App, Listed>] extends [never] ? never : App : never;
|
|
147
|
+
/** The protected members of a contract union. */
|
|
148
|
+
export type Protected<A extends Any> = A extends unknown ? A["caller"] extends typeof Anyone ? never : A : never;
|
|
149
|
+
type ServiceOf<K> = K extends Context.Key<infer I, unknown> ? I : never;
|
|
150
|
+
/**
|
|
151
|
+
* The identity required by the contract, even when its handler never reads it. A `caller`
|
|
152
|
+
* narrowed to `Anyone`, such as `Action.Any & { caller: typeof Anyone }`'s, which TypeScript
|
|
153
|
+
* keeps as `typeof Anyone | (Key & typeof Anyone)`, requires none.
|
|
154
|
+
*/
|
|
155
|
+
export type AuthenticationOf<A extends Any> = A["caller"] extends typeof Anyone ? never : ServiceOf<A["caller"]>;
|
|
156
|
+
/**
|
|
157
|
+
* Per-request requirements of `App`'s authorization and handler for each `A` it
|
|
158
|
+
* implements: those of every name `A`'s may be, so a union or an erased name owes each it may
|
|
159
|
+
* stand for.
|
|
160
|
+
*/
|
|
161
|
+
export type RequestOf<App, A extends Any> = App extends {
|
|
162
|
+
readonly "~request": infer R;
|
|
163
|
+
readonly "~authorizeRequest": infer RA;
|
|
164
|
+
} ? A extends Any ? [A["name"] & keyof R] extends [never] ? never : R[A["name"] & keyof R] | (A["caller"] extends typeof Anyone ? never : RA) | AuthenticationOf<A> : never : never;
|
|
165
|
+
type OwnActions<App> = Extract<ActionOf<App>, Any>;
|
|
166
|
+
/** Builder failures include authorization only if selected protected actions need it. */
|
|
167
|
+
export type BuildError<App, Listed extends Any = OwnActions<App>> = App extends {
|
|
168
|
+
readonly "~buildError": infer EX;
|
|
169
|
+
readonly "~authorizeBuildError": infer EAX;
|
|
170
|
+
} ? EX | ([Protected<Serving<App, Listed>>] extends [never] ? never : EAX) : never;
|
|
171
|
+
/**
|
|
172
|
+
* What a surface serving the actions `Listed` reads at startup: the services the builders of
|
|
173
|
+
* their handlers and authorization read.
|
|
174
|
+
*/
|
|
175
|
+
export type BuildServices<App, Listed extends Any = OwnActions<App>> = App extends {
|
|
176
|
+
readonly "~buildServices": infer RX;
|
|
177
|
+
readonly "~authorizeBuildServices": infer RAX;
|
|
178
|
+
} ? RX | ([Protected<Serving<App, Listed>>] extends [never] ? never : RAX) : never;
|
|
49
179
|
//#endregion
|
|
@@ -1,49 +1,167 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Unauthenticated } from "./errors.mjs";
|
|
2
|
+
import { Anyone } from "./actions.mjs";
|
|
3
|
+
import { Array, Context, Effect, Layer, Option } from "effect";
|
|
2
4
|
//#region src/internal/implementation.ts
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
* A random key segment, unique to its caller even across installed copies of this module,
|
|
7
|
+
* where a counter of each copy would repeat another's.
|
|
8
|
+
*/
|
|
9
|
+
const uniqueKey = () => Math.random().toString(36).slice(2);
|
|
10
|
+
/** What `build` makes, as a layer of its own. */
|
|
11
|
+
const memoized = (build) => {
|
|
12
|
+
const key = Context.Service(`effect-actions/Implementation/${uniqueKey()}`);
|
|
13
|
+
return {
|
|
14
|
+
key,
|
|
15
|
+
layer: Layer.effect(key, build)
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Actions bound to their handlers and their authorizer: everything one `Action.implement` call
|
|
20
|
+
* produced, one builder serving every surface.
|
|
21
|
+
* The private fields make this class nominal: a structurally similar object, including one made by
|
|
22
|
+
* spreading an implementation, is not an implementation. Its type parameters may be written
|
|
23
|
+
* out, as a package emitting declarations prints them: `A` its actions, `RequestServices` each
|
|
24
|
+
* action name's handler's per-request requirements, `EX` and `RX` the failures and services of
|
|
25
|
+
* its handlers' builder, `RA` what its authorizer reads per request, and `EAX` and `RAX` the
|
|
26
|
+
* failures and services of the authorizer's builder. Public-only selections do not acquire
|
|
27
|
+
* authorization. A plain authorization callback builds nothing.
|
|
8
28
|
*/
|
|
9
29
|
var Implementation = class Implementation {
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
this
|
|
30
|
+
actions;
|
|
31
|
+
#handlers;
|
|
32
|
+
#authorizer;
|
|
33
|
+
constructor(actions, handlers, authorizer) {
|
|
34
|
+
this.actions = actions;
|
|
35
|
+
this.#handlers = handlers;
|
|
36
|
+
this.#authorizer = authorizer;
|
|
15
37
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
/** Select contracts while retaining their handlers, authorization and shared builder. */
|
|
39
|
+
static share(actions, app) {
|
|
40
|
+
const source = Implementation.own(app);
|
|
41
|
+
return new Implementation(actions, source.#handlers, source.#authorizer);
|
|
19
42
|
}
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The key of what runs a call of `app`'s actions: its handlers' memoized layer's, which each
|
|
45
|
+
* `implement` makes, so it is unique to the implementation. Every selection retains it.
|
|
46
|
+
*/
|
|
47
|
+
static runKey(app) {
|
|
48
|
+
return Implementation.own(app).#handlers.key.key;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The builders of `app`, its handlers' and its authorizer's, as a layer. Effect memoizes each
|
|
52
|
+
* by reference within one layer graph, so every surface serving `app` there, and every
|
|
53
|
+
* implementation sharing its builder, shares one run. Static, so it stays off the
|
|
54
|
+
* public instance type.
|
|
55
|
+
*/
|
|
56
|
+
static layerOf(app) {
|
|
57
|
+
const source = Implementation.own(app);
|
|
58
|
+
return app.actions.some((action) => action.caller !== Anyone) ? Layer.merge(source.#handlers.layer, source.#authorizer.layer) : source.#handlers.layer;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The actions of `app` with their handlers, behind its authorizer, from the context
|
|
62
|
+
* `layerOf(app)` provides.
|
|
63
|
+
*/
|
|
64
|
+
static boundOf(app) {
|
|
65
|
+
const { actions } = app;
|
|
66
|
+
const source = Implementation.own(app);
|
|
67
|
+
return Effect.gen(function* () {
|
|
68
|
+
const bound = yield* source.#handlers.key;
|
|
69
|
+
const before = actions.some((action) => action.caller !== Anyone) ? yield* source.#authorizer.key : () => Effect.void;
|
|
70
|
+
return bound.filter(([action]) => actions.includes(action)).map(([action, handle]) => [action, dispatch(action, handle, before)]);
|
|
71
|
+
});
|
|
22
72
|
}
|
|
73
|
+
/** `app`, if this copy of the module made it; another installed copy's cannot be read. */
|
|
74
|
+
static own(app) {
|
|
75
|
+
if (!(#handlers in app)) throw new Error("Not an implementation made by this Action.implement: is effect-actions installed twice?");
|
|
76
|
+
return app;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/** A surface's implementations as a list. */
|
|
80
|
+
const toList = (served) => Array.ensure(served);
|
|
81
|
+
/**
|
|
82
|
+
* Refuse an action of `listed` that `held` lacks, by identity, as a stale list, marking one
|
|
83
|
+
* of a held name as another contract's.
|
|
84
|
+
*/
|
|
85
|
+
const assertHeld = (why, listed, held) => {
|
|
86
|
+
const missing = listed.filter((action) => !held.includes(action));
|
|
87
|
+
if (missing.length > 0) {
|
|
88
|
+
const names = new Set(held.map(({ name }) => name));
|
|
89
|
+
throw new Error(`Listed in actions, but ${why}: ${missing.map(({ name }) => names.has(name) ? `${name} (another contract)` : name).join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Each of `apps` narrowed to the actions `listed` holds, matched by identity, behind its own
|
|
94
|
+
* authorization and sharing its builder; one holding none of them is
|
|
95
|
+
* left out, and not built. All of `apps` as they are when nothing is listed. A listed action
|
|
96
|
+
* none of them holds is refused, as a stale list or another contract of its name.
|
|
97
|
+
*/
|
|
98
|
+
const select = (apps, listed) => {
|
|
99
|
+
if (listed === void 0) return apps;
|
|
100
|
+
assertHeld("no implementation holds it", listed, apps.flatMap((app) => app.actions));
|
|
101
|
+
return apps.flatMap((app) => {
|
|
102
|
+
const own = app.actions.filter((action) => listed.includes(action));
|
|
103
|
+
if (own.length === 0) return [];
|
|
104
|
+
return own.length === app.actions.length ? [app] : [Implementation.share(own, app)];
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Provide `layer` the handlers of `apps`. Each implementation's layer is memoized, so its
|
|
109
|
+
* builder runs once per layer graph however many surfaces serve it.
|
|
110
|
+
*/
|
|
111
|
+
const provideHandlers = (apps) => (layer) => {
|
|
112
|
+
const [first, ...rest] = apps.map((app) => Implementation.layerOf(app));
|
|
113
|
+
return first === void 0 ? layer : Layer.provide(layer, Layer.mergeAll(first, ...rest));
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* The builders of `apps` as one layer providing nothing: built above the surfaces, it runs
|
|
117
|
+
* each builder once for all of them, since every surface builds the same memoized layers.
|
|
118
|
+
*/
|
|
119
|
+
const builders = (apps) => Layer.empty.pipe(provideHandlers(apps));
|
|
120
|
+
/**
|
|
121
|
+
* Every action `apps` serve with its handler, behind its authorizer, as `provideHandlers` built
|
|
122
|
+
* them.
|
|
123
|
+
*/
|
|
124
|
+
const acquire = (apps) => Effect.map(Effect.forEach(apps, (app) => Implementation.boundOf(app)), (bound) => bound.flat());
|
|
125
|
+
/**
|
|
126
|
+
* Every action `apps` serve with its handler, behind its authorizer, their builders built as layers
|
|
127
|
+
* of the graph being built around the caller, into its memo map, as `HttpRouter` builds a
|
|
128
|
+
* middleware's dependencies: a builder acquiring them shares each builder's one run with the
|
|
129
|
+
* surfaces of its graph, whichever builds first. Outside any graph, a program under
|
|
130
|
+
* `Effect.provide` shares that layer's, and one under none builds into a map of its own. The
|
|
131
|
+
* caller's scope holds them; a surface serving them too keeps them until it is released.
|
|
132
|
+
*/
|
|
133
|
+
const built = (apps) => {
|
|
134
|
+
const [first, ...rest] = apps.map((app) => Implementation.layerOf(app));
|
|
135
|
+
return Effect.gen(function* () {
|
|
136
|
+
if (first === void 0) return [];
|
|
137
|
+
const current = yield* Effect.serviceOption(Layer.CurrentMemoMap);
|
|
138
|
+
const memoMap = Option.getOrElse(current, Layer.makeMemoMapUnsafe);
|
|
139
|
+
const scope = yield* Effect.scope;
|
|
140
|
+
const context = yield* Layer.buildWithMemoMap(Layer.mergeAll(first, ...rest), memoMap, scope);
|
|
141
|
+
return yield* Effect.provideContext(acquire(apps), context);
|
|
142
|
+
});
|
|
23
143
|
};
|
|
24
144
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* per-request services while assembling routes.
|
|
145
|
+
* One action's handler behind its admission: a protected action's identity, then its
|
|
146
|
+
* `authorize`. These run outside the action's span, so what they fail with is attributed to
|
|
147
|
+
* the surface rather than to a handler that never ran. Each call has a scope of its own, on
|
|
148
|
+
* every surface: what `authorize` and the handler acquire is released when the call ends, the
|
|
149
|
+
* handler's first, so no call needs a `Scope` of its caller.
|
|
31
150
|
*/
|
|
32
|
-
const dispatch = (
|
|
33
|
-
const handle = Object.hasOwn(handlers, action.name) ? handlers[action.name] : void 0;
|
|
34
|
-
if (!Predicate.isFunction(handle)) throw new Error(`No handler for ${group.name}.${action.name}`);
|
|
151
|
+
const dispatch = (action, handle, before) => {
|
|
35
152
|
const attributes = {
|
|
36
|
-
"action.group": group.name,
|
|
37
153
|
"action.name": action.name,
|
|
38
|
-
"action.
|
|
154
|
+
"action.read_only": action.readOnly
|
|
39
155
|
};
|
|
156
|
+
const authentication = action.caller === Anyone ? Effect.void : Effect.flatMap(Effect.serviceOption(action.caller), (actor) => Option.isSome(actor) ? Effect.void : Effect.fail(new Unauthenticated()));
|
|
157
|
+
const authorization = action.caller === Anyone ? Effect.void : Effect.suspend(() => before(action));
|
|
40
158
|
return (input) => {
|
|
41
|
-
const handled = Effect.withSpan(Effect.annotateLogs(Effect.suspend(() => handle(input)), attributes),
|
|
159
|
+
const handled = Effect.withSpan(Effect.annotateLogs(Effect.suspend(() => handle(input)), attributes), action.name, {
|
|
42
160
|
captureStackTrace: false,
|
|
43
161
|
attributes
|
|
44
162
|
});
|
|
45
|
-
return
|
|
163
|
+
return Effect.scoped(authentication.pipe(Effect.andThen(authorization), Effect.andThen(handled)));
|
|
46
164
|
};
|
|
47
165
|
};
|
|
48
166
|
//#endregion
|
|
49
|
-
export { Implementation,
|
|
167
|
+
export { Implementation, acquire, assertHeld, builders, built, memoized, provideHandlers, select, toList, uniqueKey };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { McpProtocol } from "effect/ai";
|
|
3
|
+
//#region src/internal/mcp.d.ts
|
|
4
|
+
/** An MCP request's parameters: JSON, leaving out an `undefined` one, as a caller may. */
|
|
5
|
+
export interface Params {
|
|
6
|
+
readonly _meta?: {
|
|
7
|
+
readonly [key: string]: Schema.Json;
|
|
8
|
+
};
|
|
9
|
+
readonly [key: string]: Schema.Json | undefined;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Predicate } from "effect";
|
|
2
|
+
import { McpProtocol } from "effect/ai";
|
|
3
|
+
//#region src/internal/mcp.ts
|
|
4
|
+
/**
|
|
5
|
+
* The one protocol revision served over HTTP. 2026-07-28 is stateless: every request
|
|
6
|
+
* stands alone. The stateful revisions keep a session per `initialize`, which the native
|
|
7
|
+
* HTTP runtime never expires and which no identity owns. A mixed endpoint's gate relies on it
|
|
8
|
+
* too: it decides from the routing headers, which the native runtime checks against the body
|
|
9
|
+
* of a stateless request alone, so serving a stateful revision over HTTP would open the gate.
|
|
10
|
+
*/
|
|
11
|
+
const httpProtocol = McpProtocol.v2026_07_28;
|
|
12
|
+
/** Where `ActionMcp.layerHttp` serves, and `Testing.mcpClient` calls, by default. */
|
|
13
|
+
const defaultPath = "/mcp";
|
|
14
|
+
/** The parameter a method's `mcp-name` header repeats, as the native runtime routes it. */
|
|
15
|
+
const routed = (method, params) => method === "resources/read" ? params.uri : params.name;
|
|
16
|
+
/**
|
|
17
|
+
* One stateless MCP request of `method` with `params`, as `ActionMcp.layerHttp` serves it:
|
|
18
|
+
* the headers routing it, which repeat what its body says, and its JSON-RPC body. The client
|
|
19
|
+
* metadata goes in `_meta`, under any given, such as a `progressToken`; the protocol version
|
|
20
|
+
* is always the request's own.
|
|
21
|
+
*/
|
|
22
|
+
const statelessRequest = (method, params) => {
|
|
23
|
+
const { protocolVersion } = httpProtocol;
|
|
24
|
+
const name = routed(method, params);
|
|
25
|
+
return {
|
|
26
|
+
headers: {
|
|
27
|
+
accept: "application/json, text/event-stream",
|
|
28
|
+
"mcp-protocol-version": protocolVersion,
|
|
29
|
+
"mcp-method": method,
|
|
30
|
+
...Predicate.isString(name) ? { "mcp-name": name } : {}
|
|
31
|
+
},
|
|
32
|
+
body: {
|
|
33
|
+
jsonrpc: "2.0",
|
|
34
|
+
id: 1,
|
|
35
|
+
method,
|
|
36
|
+
params: {
|
|
37
|
+
...params,
|
|
38
|
+
_meta: {
|
|
39
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
40
|
+
"io.modelcontextprotocol/clientInfo": {
|
|
41
|
+
name: "effect-actions",
|
|
42
|
+
version: "0"
|
|
43
|
+
},
|
|
44
|
+
...params._meta,
|
|
45
|
+
"io.modelcontextprotocol/protocolVersion": protocolVersion
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { defaultPath, httpProtocol, statelessRequest };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpClient, HttpRouter, HttpServer } from "effect/http";
|
|
3
|
+
//#region src/internal/memory.d.ts
|
|
4
|
+
/** What in-memory routes may leave to the host: the router, the platform, nothing per request. */
|
|
5
|
+
export type Served = HttpRouter.HttpRouter | HttpRouter.Request<"Requires", never> | HttpRouter.Request<"GlobalRequires", never> | HttpRouter.Request<"Error", any> | HttpRouter.Request<"GlobalError", any> | Layer.Success<typeof HttpServer.layerServices>;
|
|
6
|
+
//#endregion
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Effect, Layer, Predicate, Stream } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
//#region src/internal/memory.ts
|
|
4
|
+
/** Where relative request URLs resolve, so a client needs no `baseUrl`. */
|
|
5
|
+
const origin = "http://localhost/";
|
|
6
|
+
/**
|
|
7
|
+
* `request`, a streamed body turned into a web stream in the current context. The native client
|
|
8
|
+
* turns it in its send, under the in-memory `fetch`, where another client the stream calls
|
|
9
|
+
* would send to the routes too.
|
|
10
|
+
*/
|
|
11
|
+
const pulled = (request) => {
|
|
12
|
+
const body = request.body;
|
|
13
|
+
if (!Predicate.isTagged(body, "Stream")) return Effect.succeed(request);
|
|
14
|
+
return Effect.map(Stream.toReadableStreamEffect(body.stream), (readable) => HttpClientRequest.setBody(request, HttpBody.raw(readable, {
|
|
15
|
+
contentType: body.contentType,
|
|
16
|
+
contentLength: body.contentLength
|
|
17
|
+
})).pipe(HttpClientRequest.updateHeaders(() => request.headers)));
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The native `HttpClient`, sending every request to `handler` instead of the network. A
|
|
21
|
+
* relative URL resolves against `http://localhost/`, as a page there would resolve it, and a
|
|
22
|
+
* request carries the `Host` header of its URL, as one over the network does. The client is
|
|
23
|
+
* its own: the program's other clients keep their `fetch`, and get none of its requests.
|
|
24
|
+
*/
|
|
25
|
+
const clientOf = (handler) => {
|
|
26
|
+
const fetch = (input, init) => {
|
|
27
|
+
const request = new Request(input, init);
|
|
28
|
+
if (!request.headers.has("host")) request.headers.set("host", new URL(request.url).host);
|
|
29
|
+
return handler(request);
|
|
30
|
+
};
|
|
31
|
+
return Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, (native) => HttpClient.makeWith((request) => Effect.flatMap(request, (mapped) => {
|
|
32
|
+
const resolved = URL.canParse(mapped.url) ? null : URL.parse(mapped.url, origin);
|
|
33
|
+
const sent = resolved === null ? mapped : HttpClientRequest.setUrl(mapped, resolved.href);
|
|
34
|
+
return Effect.flatMap(pulled(sent), (ready) => native.postprocess(Effect.succeed(ready)).pipe(Effect.provideService(FetchHttpClient.Fetch, fetch)));
|
|
35
|
+
}), native.preprocess))).pipe(Layer.provide(Layer.fresh(FetchHttpClient.layer)));
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { clientOf };
|