@effected/kit 0.0.0-stage → 0.12.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/Distribution.js +90 -0
- package/ErrorText.js +98 -0
- package/Exclusive.js +141 -0
- package/LICENSE +21 -0
- package/LaunchContext.js +90 -0
- package/ProcessGuard.js +144 -0
- package/ProcessScope.js +95 -0
- package/README.md +98 -2
- package/Remediation.js +33 -0
- package/guard.d.ts +205 -0
- package/guard.js +3 -0
- package/index.d.ts +509 -0
- package/index.js +8 -0
- package/internal/causeMessage.js +57 -0
- package/package.json +49 -5
- package/tsdoc-metadata.json +11 -0
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @effected/kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/kit)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
Platform-free primitives shared by every front end of an Effect v4 tool. A carrier-pattern tool (a CLI, an MCP server, an LSP server) ships more than one bin behind one meta-package, and every one of those front ends needs to answer the same handful of questions the same way: which carrier was I installed through, and what version line does that make me. This package holds those primitives once, so a consumer's own `engine` package can depend on them without also depending on `@effected/cli-kit` or any other front end.
|
|
9
|
+
|
|
10
|
+
> **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
|
|
11
|
+
> Effect v4 (`effect` `~4.1.0`) and still in `0.x` development. Stable Effect
|
|
12
|
+
> makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
|
|
13
|
+
> `@effect/*` versions on the line the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effected`](https://www.npmjs.com/package/@effected/pnpm-plugin-effected).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @effected/kit effect
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add @effected/kit effect
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Node.js >=24.11.0.
|
|
33
|
+
|
|
34
|
+
All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
|
|
35
|
+
|
|
36
|
+
`effect` v4 is the only peer dependency, and it is the only dependency of any kind — no parser, no polyfill, no platform package rides in behind it. There is no IO here: every export is a schema, a reference with a default, or a pure function.
|
|
37
|
+
|
|
38
|
+
## Distribution
|
|
39
|
+
|
|
40
|
+
`Distribution` is the carrier package a tool's bins were installed through — its `name` and `version`. A plain `Schema.Struct` rather than a `Schema.Class`, because it travels as a plain object inside a machine-readable envelope and is compared structurally, never constructed on its own.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { Distribution } from "@effected/kit";
|
|
44
|
+
import { Schema } from "effect";
|
|
45
|
+
|
|
46
|
+
const decoded = Schema.decodeUnknownSync(Distribution)({ name: "@okfit/plugin", version: "0.5.1" });
|
|
47
|
+
console.log(decoded);
|
|
48
|
+
// => { name: "@okfit/plugin", version: "0.5.1" }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`DistributionField` is the `distribution` field of an envelope: `Schema.NullOr(Distribution)`, where `null` means the front end was installed directly rather than through a carrier — not "not yet known."
|
|
52
|
+
|
|
53
|
+
## CurrentDistribution
|
|
54
|
+
|
|
55
|
+
`CurrentDistribution` is a `Context.Reference<Option<Distribution>>`, not a `Context.Service`: it carries its own default (`Option.none()`), so reading it never appears in an effect's `R` and a direct install needs no provision at all. A front end's `main` provides it once, at the top of the program, once it has resolved what installed it.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { CurrentDistribution } from "@effected/kit";
|
|
59
|
+
import { Effect, Option } from "effect";
|
|
60
|
+
|
|
61
|
+
const program = Effect.gen(function* () {
|
|
62
|
+
const current = yield* CurrentDistribution;
|
|
63
|
+
return Option.isSome(current) ? current.value.name : "direct install";
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
console.log(Effect.runSync(program));
|
|
67
|
+
// => "direct install"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Providing a value scopes it to the sub-effect, the same way any `Context.Reference` scopes:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
program.pipe(
|
|
74
|
+
Effect.provideService(CurrentDistribution, Option.some({ name: "@okfit/plugin", version: "0.5.1" })),
|
|
75
|
+
);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## distributionSuffix
|
|
79
|
+
|
|
80
|
+
`distributionSuffix` turns a `CurrentDistribution` read into the trailing text a `--version` line or a startup log line appends — `""` for a direct install, `" via <name> <version>"` for a carrier. A free function, not a static on `Distribution`, because a `Schema.Struct` value carries no statics to hang it from.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { distributionSuffix } from "@effected/kit";
|
|
84
|
+
import { Option } from "effect";
|
|
85
|
+
|
|
86
|
+
distributionSuffix(Option.none());
|
|
87
|
+
// => ""
|
|
88
|
+
|
|
89
|
+
distributionSuffix(Option.some({ name: "@okfit/plugin", version: "0.5.1" }));
|
|
90
|
+
// => " via @okfit/plugin 0.5.1"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Tier
|
|
94
|
+
|
|
95
|
+
Pure tier: `effect` is the only peer, no `process`, no `node:` import, no platform package. Nothing in the kit depends on `@effected/kit` except `@effected/mcp-kit`; `@effected/cli-kit` never does — the two sit at the same layer, both consumed by a front end, never by each other.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
[MIT](LICENSE)
|
package/Remediation.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
// src/Remediation.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a caller — usually an agent — should do after a failure.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* `hint` is the human-readable instruction. `suggestedTool` and `suggestedArgs`
|
|
9
|
+
* optionally name the tool to call next and the arguments to call it with, so
|
|
10
|
+
* an agent can act without parsing the hint. The optional keys are
|
|
11
|
+
* `optionalKey`: omit them rather than passing an explicit `undefined`, which
|
|
12
|
+
* is rejected instead of silently encoded.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { Remediation } from "@effected/kit"
|
|
17
|
+
*
|
|
18
|
+
* const remediation: Remediation = {
|
|
19
|
+
* hint: "Run the validator on the whole bundle first.",
|
|
20
|
+
* suggestedTool: "validate_bundle",
|
|
21
|
+
* suggestedArgs: { strict: true },
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
const Remediation = Schema.Struct({
|
|
28
|
+
hint: Schema.String,
|
|
29
|
+
suggestedTool: Schema.optionalKey(Schema.String),
|
|
30
|
+
suggestedArgs: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown))
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export { Remediation };
|
package/guard.d.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the host process {@link ProcessGuard.run} uses. Node's
|
|
3
|
+
* `process` satisfies it.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Structural on purpose: the guard never reads a global, so a test passes a
|
|
7
|
+
* double and a front end passes `process` from its own `main`.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
interface ProcessGuardHost {
|
|
12
|
+
/** Register the `uncaughtException` listener. */
|
|
13
|
+
on(event: "uncaughtException", listener: (error: Error, origin: string) => void): unknown;
|
|
14
|
+
/** Register the `unhandledRejection` listener. */
|
|
15
|
+
on(event: "unhandledRejection", listener: (reason: unknown) => void): unknown;
|
|
16
|
+
/** Raise an `uncaughtException` to the registered listeners. Used only by {@link ProcessGuardOptions.injectCrash}. */
|
|
17
|
+
emit(event: "uncaughtException", error: Error, origin: "uncaughtException" | "unhandledRejection"): unknown;
|
|
18
|
+
/** Raise an `unhandledRejection` to the registered listeners. Used only by {@link ProcessGuardOptions.injectCrash}. */
|
|
19
|
+
emit(event: "unhandledRejection", reason: unknown, promise: Promise<unknown>): unknown;
|
|
20
|
+
/** Where every guard report is written. For a stdio server, never stdout: that is the protocol wire. */
|
|
21
|
+
readonly stderr: {
|
|
22
|
+
write(chunk: string): unknown;
|
|
23
|
+
};
|
|
24
|
+
/** End the process at once. */
|
|
25
|
+
exit(code?: number): never;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* When a stray exception or rejection ends the process.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* `"exit"` exits 1 whenever it happens. `"exitBeforeConnect"` exits 1 until
|
|
32
|
+
* the caller reports the server connected with
|
|
33
|
+
* {@link ProcessGuardControl.markConnected}, then logs and keeps serving: a
|
|
34
|
+
* server that dies mid-session drops every client attached to it. `"log"`
|
|
35
|
+
* never exits.
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
interface ProcessGuardPolicy {
|
|
40
|
+
/** For `uncaughtException`. */
|
|
41
|
+
readonly onUncaught: "exit" | "exitBeforeConnect";
|
|
42
|
+
/** For `unhandledRejection`. Defaults to `"exit"`. */
|
|
43
|
+
readonly onRejection?: "exit" | "exitBeforeConnect" | "log" | undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One crash {@link ProcessGuardOptions.injectCrash} raises: which event, and
|
|
47
|
+
* when.
|
|
48
|
+
*
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
51
|
+
interface ProcessGuardInjection {
|
|
52
|
+
/** `"load"`: before `load` is called. `"connected"`: after the first {@link ProcessGuardControl.markConnected}. */
|
|
53
|
+
readonly at: "load" | "connected";
|
|
54
|
+
/** Which event to raise. */
|
|
55
|
+
readonly kind: "uncaughtException" | "unhandledRejection";
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* What {@link ProcessGuardOptions.load} is handed: the two moments only the
|
|
59
|
+
* caller can see.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* Plain callbacks, so a caller with no Effect runtime (a
|
|
63
|
+
* `vscode-languageserver` server, a hand-written socket loop) uses them the
|
|
64
|
+
* same way an Effect launcher does: an MCP launcher passes `markConnected`
|
|
65
|
+
* to `McpStdio.launch`'s `onReady`; an LSP calls it once its connection is
|
|
66
|
+
* listening.
|
|
67
|
+
*
|
|
68
|
+
* @public
|
|
69
|
+
*/
|
|
70
|
+
interface ProcessGuardControl {
|
|
71
|
+
/**
|
|
72
|
+
* Report the server connected: from here on `"exitBeforeConnect"` logs and
|
|
73
|
+
* keeps going. Call it once the server is serving. Later calls do nothing.
|
|
74
|
+
* Safe to call after `load` has resolved, which is the usual case.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* For a server framed over stdio (MCP, LSP), "serving" is the moment its
|
|
78
|
+
* transport is built and reading stdin, before the first request arrives:
|
|
79
|
+
* from then on a client is attached, and a stray error should be logged,
|
|
80
|
+
* not end the session.
|
|
81
|
+
*/
|
|
82
|
+
readonly markConnected: () => void;
|
|
83
|
+
/**
|
|
84
|
+
* Format every report from here on with `format`, in place of the guard's
|
|
85
|
+
* own dependency-free formatter. A `format` that throws falls back to the
|
|
86
|
+
* guard's own, so a broken formatter never hides a crash.
|
|
87
|
+
*/
|
|
88
|
+
readonly useFormat: (format: (error: unknown) => string) => void;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Options for {@link ProcessGuard.run}.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
interface ProcessGuardOptions {
|
|
96
|
+
/** Prefixes every report the guard writes, as in `my-server: uncaughtException (…): …`. */
|
|
97
|
+
readonly label: string;
|
|
98
|
+
/** The process: pass `process`. */
|
|
99
|
+
readonly host: ProcessGuardHost;
|
|
100
|
+
/** Defaults to `{ onUncaught: "exit", onRejection: "exit" }`. */
|
|
101
|
+
readonly policy?: ProcessGuardPolicy | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* Import, assemble and start the server. Everything that can throw while
|
|
104
|
+
* loading belongs here, behind a dynamic `import()`, so the guards are
|
|
105
|
+
* already listening when it runs. A rejection is reported as
|
|
106
|
+
* `startup failed` and exits 1, whatever the policy.
|
|
107
|
+
*/
|
|
108
|
+
readonly load: (guard: ProcessGuardControl) => Promise<unknown>;
|
|
109
|
+
/**
|
|
110
|
+
* For a test of the guards themselves: raise one stray `kind` at `at`, so
|
|
111
|
+
* both halves of a policy can be driven end to end in a real process.
|
|
112
|
+
*
|
|
113
|
+
* @remarks
|
|
114
|
+
* - `"load"` raises it once both listeners are installed and before
|
|
115
|
+
* `load` is called, then waits for the guard to handle it: `load` is
|
|
116
|
+
* not called until the listener has run. If a test double's `exit`
|
|
117
|
+
* throws instead of ending the process, `run` rejects with what it
|
|
118
|
+
* threw and never calls `load`. The pre-connect half of the policy
|
|
119
|
+
* applies, so `"exitBeforeConnect"` exits 1 here, and only an
|
|
120
|
+
* `onRejection` of `"log"` lets the process go on to load and serve.
|
|
121
|
+
* The report uses the guard's own formatter, since no `useFormat` has
|
|
122
|
+
* run yet.
|
|
123
|
+
* - `"connected"` raises it after the first `markConnected`, where
|
|
124
|
+
* `"exitBeforeConnect"` logs and keeps serving. It is never raised if
|
|
125
|
+
* `markConnected` is never called. A throw from a test double's `exit`
|
|
126
|
+
* there is dropped.
|
|
127
|
+
*
|
|
128
|
+
* **The `"connected"` report is asynchronous.** It is raised on a later
|
|
129
|
+
* tick, a `setTimeout(0)` after `markConnected`, so it can land after the
|
|
130
|
+
* first responses the server sends: a test that reads stderr once, right
|
|
131
|
+
* after its first response, can see nothing. Wait for the report (with
|
|
132
|
+
* `McpProcess.stderrUntil` from `@effected/mcp-kit/testing`, for an MCP
|
|
133
|
+
* server) rather than reading it once.
|
|
134
|
+
*
|
|
135
|
+
* Either is raised only through `host.emit`, on a `setTimeout(0)` tick,
|
|
136
|
+
* never as a real throw or rejection, so the guard's listeners handle it
|
|
137
|
+
* the same way under the real `process` and under a test double. Under
|
|
138
|
+
* `process`, every listener registered for the event sees it, not only
|
|
139
|
+
* the guard's. It carries an `[injected]` message; a rejection is emitted
|
|
140
|
+
* with an already-handled rejected promise. `undefined`, or an `at` or
|
|
141
|
+
* `kind` outside these values, does nothing at all. Wire it to an
|
|
142
|
+
* environment variable only a test sets.
|
|
143
|
+
*/
|
|
144
|
+
readonly injectCrash?: ProcessGuardInjection | undefined;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Transport-neutral crash guards for a server process, installed before the
|
|
148
|
+
* server's module graph loads. Imported from `@effected/kit/guard`.
|
|
149
|
+
*
|
|
150
|
+
* @remarks
|
|
151
|
+
* {@link ProcessGuard.run} registers `uncaughtException` and
|
|
152
|
+
* `unhandledRejection` listeners on `host`, then awaits `load`. It launches
|
|
153
|
+
* nothing itself: `load` starts the server, over whatever transport, and
|
|
154
|
+
* calls `markConnected` once it is serving. `@effected/mcp-kit/guard`'s
|
|
155
|
+
* `McpGuard.run` is this guard with an MCP stdio launch in its `load`.
|
|
156
|
+
*
|
|
157
|
+
* - This entrypoint has no runtime import at all, so a throw while the
|
|
158
|
+
* server graph evaluates is still reported on stderr.
|
|
159
|
+
* - A `load` that rejects is reported as `startup failed` and exits 1
|
|
160
|
+
* whatever the policy. Left to a log-only rejection listener, it would let
|
|
161
|
+
* the event loop drain and exit 0 with no server.
|
|
162
|
+
* - Installing an `unhandledRejection` listener switches off Node's default
|
|
163
|
+
* of throwing on one, so `"log"` really does keep the process running.
|
|
164
|
+
* - An exit from a guard skips every finalizer the server registered.
|
|
165
|
+
* - Every report is one line: `<label>: uncaughtException (<origin>): …`,
|
|
166
|
+
* `<label>: unhandledRejection: …` or `<label>: startup failed: …`.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* import { ProcessGuard } from "@effected/kit/guard";
|
|
171
|
+
*
|
|
172
|
+
* await ProcessGuard.run({
|
|
173
|
+
* label: "my-lsp",
|
|
174
|
+
* host: process,
|
|
175
|
+
* policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
|
|
176
|
+
* load: async (guard) => {
|
|
177
|
+
* const { startServer } = await import("./server.js");
|
|
178
|
+
* await startServer();
|
|
179
|
+
* guard.markConnected();
|
|
180
|
+
* },
|
|
181
|
+
* });
|
|
182
|
+
* ```
|
|
183
|
+
*
|
|
184
|
+
* @public
|
|
185
|
+
*/
|
|
186
|
+
export declare class ProcessGuard {
|
|
187
|
+
private constructor();
|
|
188
|
+
/**
|
|
189
|
+
* Parse a test-only crash-injection setting into {@link ProcessGuardOptions.injectCrash}: `<at>:<kind>`, where `at`
|
|
190
|
+
* is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
|
|
191
|
+
* value, is `undefined` (no injection), so a launcher can pass an environment variable straight through and every
|
|
192
|
+
* launcher shares one grammar.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```ts
|
|
196
|
+
* ProcessGuard.parseInjectCrash("connected:unhandledRejection"); // => { at: "connected", kind: "unhandledRejection" }
|
|
197
|
+
* ProcessGuard.parseInjectCrash("later:boom"); // => undefined
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
static readonly parseInjectCrash: (value: string | undefined) => ProcessGuardInjection | undefined;
|
|
201
|
+
/** Install the guards, then run `load`. Resolves once `load` has resolved. */
|
|
202
|
+
static readonly run: (options: ProcessGuardOptions) => Promise<void>;
|
|
203
|
+
}
|
|
204
|
+
export type { ProcessGuardControl, ProcessGuardHost, ProcessGuardInjection, ProcessGuardOptions, ProcessGuardPolicy };
|
|
205
|
+
//# sourceMappingURL=guard.d.ts.map
|
package/guard.js
ADDED