@smthrs/plugin 0.0.0-stage → 1.0.0-rc.13
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/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/dist/cjs/Config.d.ts +129 -0
- package/dist/cjs/Config.d.ts.map +1 -0
- package/dist/cjs/Config.js +115 -0
- package/dist/cjs/Config.js.map +7 -0
- package/dist/cjs/Hooks.d.ts +176 -0
- package/dist/cjs/Hooks.d.ts.map +1 -0
- package/dist/cjs/Hooks.js +34 -0
- package/dist/cjs/Hooks.js.map +7 -0
- package/dist/cjs/Kernel.d.ts +81 -0
- package/dist/cjs/Kernel.d.ts.map +1 -0
- package/dist/cjs/Kernel.js +57 -0
- package/dist/cjs/Kernel.js.map +7 -0
- package/dist/cjs/Plugin.d.ts +71 -0
- package/dist/cjs/Plugin.d.ts.map +1 -0
- package/dist/cjs/Plugin.js +25 -0
- package/dist/cjs/Plugin.js.map +7 -0
- package/dist/cjs/PluginError.d.ts +50 -0
- package/dist/cjs/PluginError.d.ts.map +1 -0
- package/dist/cjs/PluginError.js +58 -0
- package/dist/cjs/PluginError.js.map +7 -0
- package/dist/cjs/Plugins.d.ts +97 -0
- package/dist/cjs/Plugins.d.ts.map +1 -0
- package/dist/cjs/Plugins.js +169 -0
- package/dist/cjs/Plugins.js.map +7 -0
- package/dist/cjs/Resolve.d.ts +128 -0
- package/dist/cjs/Resolve.d.ts.map +1 -0
- package/dist/cjs/Resolve.js +346 -0
- package/dist/cjs/Resolve.js.map +7 -0
- package/dist/cjs/index.d.ts +83 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/Boundary.d.ts +96 -0
- package/dist/cjs/internal/Boundary.d.ts.map +1 -0
- package/dist/cjs/internal/Boundary.js +129 -0
- package/dist/cjs/internal/Boundary.js.map +7 -0
- package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
- package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
- package/dist/cjs/internal/mergePluginLayers.js +57 -0
- package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +23 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
- package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/Config.d.ts +129 -0
- package/dist/esm/Config.d.ts.map +1 -0
- package/dist/esm/Config.js +160 -0
- package/dist/esm/Config.js.map +1 -0
- package/dist/esm/Hooks.d.ts +176 -0
- package/dist/esm/Hooks.d.ts.map +1 -0
- package/dist/esm/Hooks.js +56 -0
- package/dist/esm/Hooks.js.map +1 -0
- package/dist/esm/Kernel.d.ts +81 -0
- package/dist/esm/Kernel.d.ts.map +1 -0
- package/dist/esm/Kernel.js +58 -0
- package/dist/esm/Kernel.js.map +1 -0
- package/dist/esm/Plugin.d.ts +71 -0
- package/dist/esm/Plugin.d.ts.map +1 -0
- package/dist/esm/Plugin.js +20 -0
- package/dist/esm/Plugin.js.map +1 -0
- package/dist/esm/PluginError.d.ts +50 -0
- package/dist/esm/PluginError.d.ts.map +1 -0
- package/dist/esm/PluginError.js +53 -0
- package/dist/esm/PluginError.js.map +1 -0
- package/dist/esm/Plugins.d.ts +97 -0
- package/dist/esm/Plugins.d.ts.map +1 -0
- package/dist/esm/Plugins.js +190 -0
- package/dist/esm/Plugins.js.map +1 -0
- package/dist/esm/Resolve.d.ts +128 -0
- package/dist/esm/Resolve.d.ts.map +1 -0
- package/dist/esm/Resolve.js +369 -0
- package/dist/esm/Resolve.js.map +1 -0
- package/dist/esm/index.d.ts +83 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +48 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/Boundary.d.ts +96 -0
- package/dist/esm/internal/Boundary.d.ts.map +1 -0
- package/dist/esm/internal/Boundary.js +162 -0
- package/dist/esm/internal/Boundary.js.map +1 -0
- package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
- package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
- package/dist/esm/internal/mergePluginLayers.js +41 -0
- package/dist/esm/internal/mergePluginLayers.js.map +1 -0
- package/dist/esm/internal/snapshotCacheEnvironment.d.ts +23 -0
- package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
- package/dist/esm/internal/snapshotCacheEnvironment.js +64 -0
- package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
- package/docs/README.md +133 -0
- package/docs/api.md +524 -0
- package/docs/concepts/configuration.md +111 -0
- package/docs/concepts/hook-kinds.md +107 -0
- package/docs/concepts/resolution.md +132 -0
- package/docs/guides/cache-identity.md +106 -0
- package/docs/guides/contribute-services.md +84 -0
- package/docs/guides/host-your-own-hooks.md +129 -0
- package/docs/guides/order-handlers.md +76 -0
- package/docs/guides/select-plugins.md +72 -0
- package/docs/guides/testing.md +149 -0
- package/docs/installation.md +75 -0
- package/docs/quickstart.md +152 -0
- package/docs/troubleshooting.md +180 -0
- package/package.json +179 -3
- package/src/Config.ts +207 -0
- package/src/Hooks.ts +212 -0
- package/src/Kernel.ts +119 -0
- package/src/Plugin.ts +80 -0
- package/src/PluginError.ts +62 -0
- package/src/Plugins.ts +271 -0
- package/src/Resolve.ts +539 -0
- package/src/index.ts +91 -0
- package/src/internal/Boundary.ts +252 -0
- package/src/internal/mergePluginLayers.ts +57 -0
- package/src/internal/snapshotCacheEnvironment.ts +78 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Control the order handlers run in"
|
|
3
|
+
description: "Pick between the plugin-wide enforce group and the per-hook order object, and know which one wins when they disagree."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Two levers decide when your handler runs. Use `enforce` when the plugin as a
|
|
9
|
+
whole belongs early or late. Use the per-hook `order` when one hook needs a
|
|
10
|
+
different position from the rest of the plugin.
|
|
11
|
+
|
|
12
|
+
## Move the whole plugin with enforce
|
|
13
|
+
|
|
14
|
+
`enforce` puts a plugin in the `pre`, normal, or `post` group. Within a group,
|
|
15
|
+
plugins keep the order you listed them in:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { make } from "@smthrs/plugin"
|
|
19
|
+
|
|
20
|
+
const early = make({ name: "flows-plugin-early", enforce: "pre", hooks: { configResolved: handler } })
|
|
21
|
+
const late = make({ name: "flows-plugin-late", enforce: "post", hooks: { configResolved: handler } })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The resulting list is the resolved plugin order, and it decides two things
|
|
25
|
+
beyond hook dispatch: the order `kernel.layer` merges plugin layers in, and the
|
|
26
|
+
order plugin identities appear in a declared cache identity.
|
|
27
|
+
|
|
28
|
+
## Move one hook with the ordering object
|
|
29
|
+
|
|
30
|
+
Replace the bare handler with `{ order, handler }` to position that hook alone:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const plugin = make({
|
|
34
|
+
name: "flows-plugin-mixed",
|
|
35
|
+
hooks: {
|
|
36
|
+
config: handler,
|
|
37
|
+
configResolved: { order: "pre", handler }
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Here `config` runs at the plugin's normal position and `configResolved` runs
|
|
43
|
+
early. The object form is the only way to give one plugin two different
|
|
44
|
+
positions.
|
|
45
|
+
|
|
46
|
+
## The per-hook order wins
|
|
47
|
+
|
|
48
|
+
When the two levers disagree, the per-hook `order` wins, because it is applied
|
|
49
|
+
second and re-partitions the already-sorted list:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const plugins = [
|
|
53
|
+
make({ name: "flows-plugin-a", enforce: "post", hooks: { configResolved: { order: "pre", handler } } }),
|
|
54
|
+
make({ name: "flows-plugin-b", enforce: "pre", hooks: { configResolved: { order: "post", handler } } })
|
|
55
|
+
]
|
|
56
|
+
// configResolved order: flows-plugin-a, then flows-plugin-b.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This is Vite's rule, kept verbatim, so a plugin ported from Vite orders the same
|
|
60
|
+
way here. The full statement of it is in
|
|
61
|
+
[Resolution](../concepts/resolution.md).
|
|
62
|
+
|
|
63
|
+
## Do not order by list position alone
|
|
64
|
+
|
|
65
|
+
Within a group, the array order is stable and it is the tiebreaker. It is also
|
|
66
|
+
the weakest of the three signals: a host that concatenates presets, or a user
|
|
67
|
+
who reorders a config file, changes it without meaning to. Declare `enforce` or
|
|
68
|
+
`order` when the position matters, and leave both off when it does not.
|
|
69
|
+
|
|
70
|
+
## Ordering is not a dependency system
|
|
71
|
+
|
|
72
|
+
There is no `dependsOn`, and no cycle detection, because there are no
|
|
73
|
+
dependencies to cycle. If your plugin needs another plugin's contribution, read
|
|
74
|
+
it from the value the hook threads through: a waterfall handler sees what the
|
|
75
|
+
earlier handlers produced, and a `configResolved` observer sees the whole
|
|
76
|
+
resolved configuration.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Include a plugin conditionally"
|
|
3
|
+
description: "Use apply to select a plugin by host target or by a predicate over the pre-resolution configuration, and know what selection does not excuse."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 3
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A plugin can decide it does not belong in this composition. Declare `apply`,
|
|
9
|
+
and the kernel drops the plugin before it contributes a handler, a layer, or a
|
|
10
|
+
cache identity.
|
|
11
|
+
|
|
12
|
+
## Select by host target
|
|
13
|
+
|
|
14
|
+
`apply: "engine"` and `apply: "harness"` name the host the plugin is for. The
|
|
15
|
+
kernel compares the literal with the `target` option, which defaults to
|
|
16
|
+
`"engine"`:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Kernel, make } from "@smthrs/plugin"
|
|
20
|
+
|
|
21
|
+
const list = [
|
|
22
|
+
make({ name: "flows-plugin-shared" }),
|
|
23
|
+
make({ name: "flows-plugin-harness-only", apply: "harness" })
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// Engine kernel: only flows-plugin-shared is selected.
|
|
27
|
+
const engine = Kernel.make(list)
|
|
28
|
+
|
|
29
|
+
// Harness kernel: both are selected.
|
|
30
|
+
const harness = Kernel.make(list, {}, { target: "harness" })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A plugin with no `apply` is always selected. This is how one shared preset
|
|
34
|
+
serves two hosts: list every plugin once, and let each host take its half.
|
|
35
|
+
|
|
36
|
+
## Select by a predicate over the configuration
|
|
37
|
+
|
|
38
|
+
A predicate receives the pre-resolution configuration, the same value you passed
|
|
39
|
+
to `Kernel.make`, and returns a boolean:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
const fast = make({
|
|
43
|
+
name: "flows-plugin-fast",
|
|
44
|
+
apply: (config) => config["mode"] === "fast"
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const kernel = Kernel.make([fast], { mode: "fast" })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The configuration a predicate sees is a snapshot taken before the first
|
|
51
|
+
predicate runs, so a predicate cannot observe another predicate's side effects,
|
|
52
|
+
and a caller cannot mutate the object between two predicate calls.
|
|
53
|
+
|
|
54
|
+
It is the _pre-resolution_ configuration: the `config` waterfall has not run
|
|
55
|
+
yet, so a predicate cannot see a namespace another plugin is about to
|
|
56
|
+
contribute. Selection has to settle before the waterfall, because the waterfall
|
|
57
|
+
runs the plugins selection chose.
|
|
58
|
+
|
|
59
|
+
A predicate that throws fails resolution with `apply_failed`, and its raw
|
|
60
|
+
failure is not retained. A predicate that returns anything other than a boolean
|
|
61
|
+
fails with `invalid_plugin`. Keep predicates pure and total.
|
|
62
|
+
|
|
63
|
+
## What selection does not excuse
|
|
64
|
+
|
|
65
|
+
Every plugin record is validated before `apply` runs, so a malformed plugin
|
|
66
|
+
fails startup even when the predicate would have excluded it. Selection cannot
|
|
67
|
+
hide a typo.
|
|
68
|
+
|
|
69
|
+
Hook names are checked after selection, and that asymmetry is deliberate: a
|
|
70
|
+
shared preset may carry `apply: "harness"` plugins whose hooks only the harness
|
|
71
|
+
catalog declares, and it still resolves under a bare engine kernel because those
|
|
72
|
+
plugins were never selected.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Test a plugin"
|
|
3
|
+
description: "Exercise a plugin without booting a host: resolve a list, dispatch a hook, assert on ordering and typed failures, and stub the kernel out of a composition under test."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 6
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A plugin is a plain record and the dispatcher is a plain service, so a plugin
|
|
9
|
+
test needs no host, no engine, and no I/O. Resolve the list, dispatch the hook,
|
|
10
|
+
assert on the result.
|
|
11
|
+
|
|
12
|
+
## Dispatch a hook without a kernel
|
|
13
|
+
|
|
14
|
+
`Resolve.resolve` gives you the catalog and `Plugins.make` gives you the
|
|
15
|
+
dispatcher. Use them directly when the test is about one hook, with the hook
|
|
16
|
+
interface as an explicit type argument:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { type FlowsPlugin, Plugins, Resolve, type WaterfallHook } from "@smthrs/plugin"
|
|
20
|
+
import * as Effect from "effect/Effect"
|
|
21
|
+
import { expect, it } from "vitest"
|
|
22
|
+
|
|
23
|
+
interface HostHooks {
|
|
24
|
+
readonly tools: WaterfallHook<
|
|
25
|
+
(tools: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<string> | void>
|
|
26
|
+
>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const subject: FlowsPlugin<HostHooks> = {
|
|
30
|
+
name: "flows-plugin-subject",
|
|
31
|
+
hooks: { tools: (tools) => Effect.succeed([...tools, "read", "write"]) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it("contributes its tools", async () => {
|
|
35
|
+
const resolved = await Effect.runPromise(
|
|
36
|
+
Resolve.resolve<HostHooks>([subject], { hooks: { tools: "waterfall" } })
|
|
37
|
+
)
|
|
38
|
+
const tools = await Effect.runPromise(
|
|
39
|
+
Plugins.make<HostHooks>(resolved).waterfall("tools", [] as ReadonlyArray<string>, (_previous, next) => next)
|
|
40
|
+
)
|
|
41
|
+
expect(tools).toEqual(["read", "write"])
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use `Kernel.make` instead when the test is about the whole startup path: the
|
|
46
|
+
waterfall, the freeze, the observers, and the merged layer together.
|
|
47
|
+
|
|
48
|
+
## Startup hooks see no services
|
|
49
|
+
|
|
50
|
+
The shared `config` and `configResolved` hooks are context-free: `Kernel.make`
|
|
51
|
+
runs them before any plugin layer exists and supplies nothing itself, so a
|
|
52
|
+
handler that reads a service does not compile against `FlowsHooks`, and one
|
|
53
|
+
cast past the type fails at runtime with `hook_failed` on that hook. Provide
|
|
54
|
+
the service inside the hook, or, when a host's startup hooks genuinely require
|
|
55
|
+
services, declare a separate hook interface and provide them to the startup
|
|
56
|
+
Effect. `Kernel.make<H>` carries the requirement as `Kernel.StartupContext<H>`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
interface HostHooks {
|
|
60
|
+
readonly config: WaterfallHook<
|
|
61
|
+
(config: FlowsConfig) => Effect.Effect<Partial<FlowsConfig> | void, never, Settings>
|
|
62
|
+
>
|
|
63
|
+
readonly configResolved: ParallelHook<(config: ResolvedConfig) => Effect.Effect<void, never, Settings>>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const kernel = await Effect.runPromise(
|
|
67
|
+
Kernel.make<HostHooks>([reader], {}, { hooks: { config: "waterfall", configResolved: "parallel" } }).pipe(
|
|
68
|
+
Effect.provideService(Settings, { enabled: true })
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Without the `provideService`, the program does not compile.
|
|
74
|
+
|
|
75
|
+
## Assert the order, not just the membership
|
|
76
|
+
|
|
77
|
+
`resolved.plugins` is the resolved plugin order and
|
|
78
|
+
`resolved.handlers.get(hook)` is the ordered handler list for one hook, each
|
|
79
|
+
record carrying the plugin that declared it:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const names = (resolved: Resolve.Resolved, hook: string) =>
|
|
83
|
+
(resolved.handlers.get(hook) ?? []).map((record) => record.plugin)
|
|
84
|
+
|
|
85
|
+
expect(names(resolved, "configResolved")).toEqual(["early", "normal", "late"])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Assert both when a plugin declares a per-hook `order`: the two lists differ on
|
|
89
|
+
purpose, and only the handler list proves the hook is positioned the way you
|
|
90
|
+
meant.
|
|
91
|
+
|
|
92
|
+
## Assert typed failures by code
|
|
93
|
+
|
|
94
|
+
Every refusal is a `PluginError` with a `code`, and most carry the plugin, the
|
|
95
|
+
hook, and the value path. Flip the Effect and match the fields you care about:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { Kernel } from "@smthrs/plugin"
|
|
99
|
+
|
|
100
|
+
const error = await Effect.runPromise(
|
|
101
|
+
Kernel.make([{ name: "duplicate" }, { name: "duplicate" }]).pipe(Effect.flip)
|
|
102
|
+
)
|
|
103
|
+
expect(error).toMatchObject({ code: "duplicate_name", plugin: "duplicate" })
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Match on `code`, `plugin`, `hook`, and `path`, not on `message`. The codes are a
|
|
107
|
+
closed set, listed in [Troubleshooting](../troubleshooting.md); the messages are
|
|
108
|
+
prose and may be reworded.
|
|
109
|
+
|
|
110
|
+
## Observer failures are a return value
|
|
111
|
+
|
|
112
|
+
A failing `configResolved` handler does not fail startup, so an assertion that
|
|
113
|
+
the kernel succeeded proves nothing about it. Read `observerErrors`:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const kernel = await Effect.runPromise(
|
|
117
|
+
Kernel.make([{ name: "noisy", hooks: { configResolved: () => Effect.fail("boom") } }])
|
|
118
|
+
)
|
|
119
|
+
expect(kernel.observerErrors.map((error) => error.plugin)).toEqual(["noisy"])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Stub the kernel out of a composition under test
|
|
123
|
+
|
|
124
|
+
When the subject is the host rather than the plugins, give it a dispatcher with
|
|
125
|
+
no plugins at all:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const dispatcher = Plugins.makeNoop()
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`Plugins.layerNoop` is the same thing as a layer, for a composition that resolves
|
|
132
|
+
the shared `Plugins` service tag. Both dispatch nothing: a parallel hook returns
|
|
133
|
+
an empty error list, a first hook answers `Option.none()`, and a waterfall
|
|
134
|
+
returns the value it was given.
|
|
135
|
+
|
|
136
|
+
## Test the pure pieces directly
|
|
137
|
+
|
|
138
|
+
`Config.merge` and `Config.deepFreeze` are ordinary functions that throw a
|
|
139
|
+
`PluginError` rather than returning an Effect, so a merge-semantics test needs no
|
|
140
|
+
runtime:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { Config } from "@smthrs/plugin"
|
|
144
|
+
|
|
145
|
+
expect(Config.merge({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } })
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`Hooks.handlerOf` and `Hooks.orderOf` read either hook entry form and are the
|
|
149
|
+
same shape of test.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Installation"
|
|
3
|
+
description: "Install @smthrs/plugin, its runtime requirements, its import forms, and the subpaths its export map blocks."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Install the package
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @smthrs/plugin@next effect
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The `next` tag carries the 1.0 release candidates.
|
|
15
|
+
|
|
16
|
+
Install `effect` explicitly, at exactly `4.0.5`. The kernel depends on it, but your
|
|
17
|
+
plugins import `Effect`, `Layer`, and `Option` directly, so `effect` belongs in
|
|
18
|
+
your own dependency list, inside the range the kernel accepts. A different
|
|
19
|
+
`effect` major gives you two copies of the runtime and two sets of service tags.
|
|
20
|
+
|
|
21
|
+
The package requires Node.js 26.4.0 or later and ships as both ESM and
|
|
22
|
+
CommonJS with TypeScript declarations. Its runtime dependencies install with it:
|
|
23
|
+
[`@smthrs/flow`](/api/flow) supplies `Action.CacheEnvironment` for
|
|
24
|
+
[cache identity](./guides/cache-identity.md), and
|
|
25
|
+
[`@smthrs/canonical`](/api/canonical) supplies immutable catalog maps.
|
|
26
|
+
|
|
27
|
+
## Import forms
|
|
28
|
+
|
|
29
|
+
The root entry point exports the plugin, hook, and error declarations directly,
|
|
30
|
+
and the four stateful modules as namespaces:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { Config, Kernel, Plugins, Resolve } from "@smthrs/plugin"
|
|
34
|
+
import type { FlowsHooks, FlowsPlugin, PluginInput } from "@smthrs/plugin"
|
|
35
|
+
import { engineHooks, make, PluginError } from "@smthrs/plugin"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Each module is also importable from its own subpath, which is the form the
|
|
39
|
+
[API reference](./api.md) uses:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import * as Config from "@smthrs/plugin/Config"
|
|
43
|
+
import * as Kernel from "@smthrs/plugin/Kernel"
|
|
44
|
+
import * as Plugins from "@smthrs/plugin/Plugins"
|
|
45
|
+
import * as Resolve from "@smthrs/plugin/Resolve"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The full list of public subpaths is `@smthrs/plugin/Config`,
|
|
49
|
+
`@smthrs/plugin/Hooks`, `@smthrs/plugin/Kernel`, `@smthrs/plugin/Plugin`,
|
|
50
|
+
`@smthrs/plugin/PluginError`, `@smthrs/plugin/Plugins`, and
|
|
51
|
+
`@smthrs/plugin/Resolve`. Two forms are blocked in the export map:
|
|
52
|
+
`@smthrs/plugin/internal/*` and `@smthrs/plugin/*/index`.
|
|
53
|
+
`@smthrs/plugin/package.json` is exported.
|
|
54
|
+
|
|
55
|
+
## The augmentation specifier
|
|
56
|
+
|
|
57
|
+
A host that declares its own hooks augments the interface in the root module,
|
|
58
|
+
never in a subpath:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
declare module "@smthrs/plugin" {
|
|
62
|
+
interface FlowsHooks {
|
|
63
|
+
// your hooks
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`FlowsHooks` is declared in the entry point precisely so this specifier works:
|
|
69
|
+
TypeScript can augment an interface only in the module that declares it. See
|
|
70
|
+
[Declare hooks for your host](./guides/host-your-own-hooks.md).
|
|
71
|
+
|
|
72
|
+
## Next step
|
|
73
|
+
|
|
74
|
+
Build a host with one hook of its own and run it in the
|
|
75
|
+
[Quickstart](./quickstart.md).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Quickstart"
|
|
3
|
+
description: "Build a host with a hook of its own: declare the catalog, write two plugins, run the kernel, and dispatch the hook to a typed result."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This quickstart builds a small host end to end. The host offers plugins one
|
|
9
|
+
extension point of its own, a `tools` waterfall, alongside the kernel's config
|
|
10
|
+
lifecycle. By the end you will have run the kernel, read a frozen configuration,
|
|
11
|
+
and dispatched a hook whose result is typed by the declaration you wrote.
|
|
12
|
+
|
|
13
|
+
The example uses two files: `host.ts` declares the hook catalog, and `main.ts`
|
|
14
|
+
defines the plugins and runs the host. There are no services to provide.
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- Node.js 26.4.0 or later.
|
|
19
|
+
- A TypeScript project with the package installed:
|
|
20
|
+
`npm install @smthrs/plugin@next effect`.
|
|
21
|
+
|
|
22
|
+
## Declare the host's hook catalog
|
|
23
|
+
|
|
24
|
+
A host declares its hooks twice: once in the type system, so plugin authors get
|
|
25
|
+
completion and excess-key errors, and once as a runtime catalog, so the kernel
|
|
26
|
+
can refuse a hook name nobody dispatches.
|
|
27
|
+
|
|
28
|
+
Create `host.ts`:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { engineHooks, type WaterfallHook } from "@smthrs/plugin"
|
|
32
|
+
import type * as Effect from "effect/Effect"
|
|
33
|
+
|
|
34
|
+
declare module "@smthrs/plugin" {
|
|
35
|
+
interface FlowsHooks {
|
|
36
|
+
/** Contributes or rewrites the tool names the host offers. */
|
|
37
|
+
readonly tools: WaterfallHook<
|
|
38
|
+
(tools: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<string> | void>
|
|
39
|
+
>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The runtime catalog: the kernel's two config hooks plus this host's one. */
|
|
44
|
+
export const hooks = Object.freeze(
|
|
45
|
+
{
|
|
46
|
+
...engineHooks,
|
|
47
|
+
tools: "waterfall"
|
|
48
|
+
} as const
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The two declarations must agree. The interface says what compiles; the runtime
|
|
53
|
+
catalog says what dispatches. `WaterfallHook` in the interface and
|
|
54
|
+
`"waterfall"` in the catalog are the same claim, written for the two audiences.
|
|
55
|
+
|
|
56
|
+
## Write two plugins
|
|
57
|
+
|
|
58
|
+
Add all remaining TypeScript snippets to `main.ts` in order, starting with the
|
|
59
|
+
complete import block and the first plugin.
|
|
60
|
+
|
|
61
|
+
A plugin is a record. This one contributes a configuration namespace and two
|
|
62
|
+
tools.
|
|
63
|
+
|
|
64
|
+
Create `main.ts` beside `host.ts`:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { type FlowsHooks, Kernel, make } from "@smthrs/plugin"
|
|
68
|
+
import * as Effect from "effect/Effect"
|
|
69
|
+
import { hooks } from "./host.ts"
|
|
70
|
+
|
|
71
|
+
const editor = make<FlowsHooks>({
|
|
72
|
+
name: "flows-plugin-editor",
|
|
73
|
+
hooks: {
|
|
74
|
+
config: () => Effect.succeed({ editor: { readOnly: false } }),
|
|
75
|
+
tools: (tools) => Effect.succeed([...tools, "read", "write"])
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`make` is an identity function that pins the literal to `FlowsPlugin<H>`. Pass
|
|
81
|
+
the hook interface explicitly, as above: it is what types the `tools` parameter
|
|
82
|
+
as `ReadonlyArray<string>` and what makes a misspelled hook key fail at the
|
|
83
|
+
definition site instead of at startup.
|
|
84
|
+
|
|
85
|
+
The second plugin runs first, because it is declared `enforce: "pre"`, and it
|
|
86
|
+
reads what the host resolved rather than adding to it:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const audit = make<FlowsHooks>({
|
|
90
|
+
name: "flows-plugin-audit",
|
|
91
|
+
enforce: "pre",
|
|
92
|
+
hooks: {
|
|
93
|
+
configResolved: (config) => Effect.log(`resolved namespaces: ${Object.keys(config).join(", ")}`)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Run the kernel
|
|
99
|
+
|
|
100
|
+
`Kernel.make` takes the plugin list, the pre-resolution configuration, and the
|
|
101
|
+
host's options. It resolves the list, runs the config waterfall, freezes the
|
|
102
|
+
result, notifies the observers, and merges the plugin layers:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const kernel = await Effect.runPromise(
|
|
106
|
+
Kernel.make([editor, audit], { host: { name: "quickstart" } }, { target: "harness", hooks })
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
console.log(kernel.config)
|
|
110
|
+
// { host: { name: "quickstart" }, editor: { readOnly: false } }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`target` names which kind of host you are, and it is what a plugin's
|
|
114
|
+
`apply: "engine" | "harness"` selector is compared against. It defaults to
|
|
115
|
+
`"engine"`, meaning the Smithers durable engine, so pass `"harness"` for any
|
|
116
|
+
other host, including this one.
|
|
117
|
+
|
|
118
|
+
## Dispatch the hook
|
|
119
|
+
|
|
120
|
+
`kernel.plugins` is the dispatcher. A waterfall needs one more argument than the
|
|
121
|
+
other kinds, the merge function that folds each handler's answer into the value
|
|
122
|
+
the next handler sees:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const tools = await Effect.runPromise(
|
|
126
|
+
kernel.plugins.waterfall("tools", [] as ReadonlyArray<string>, (_previous, next) => next)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
console.log(tools) // ["read", "write"]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The literal `"tools"` is checked against the hook interface: a name that is not
|
|
133
|
+
a waterfall in `FlowsHooks` does not compile, and `tools` is typed as the
|
|
134
|
+
handler's argument type, not as `unknown`.
|
|
135
|
+
|
|
136
|
+
## What you just built
|
|
137
|
+
|
|
138
|
+
- A hook catalog only your host dispatches. Another host augmenting the same
|
|
139
|
+
interface still dispatches only the catalog it supplied.
|
|
140
|
+
- A plugin list resolved once into a frozen catalog. Mutating `editor` after
|
|
141
|
+
this point changes nothing about dispatch.
|
|
142
|
+
- A configuration every plugin agreed on, deeply copied and frozen before the
|
|
143
|
+
first plugin saw it.
|
|
144
|
+
|
|
145
|
+
## Next steps
|
|
146
|
+
|
|
147
|
+
- [Hook kinds](./concepts/hook-kinds.md) for what `sequential`, `parallel`,
|
|
148
|
+
`first`, and `waterfall` each promise.
|
|
149
|
+
- [Control the order handlers run in](./guides/order-handlers.md) for `enforce`
|
|
150
|
+
and the per-hook `order`.
|
|
151
|
+
- [Contribute services from a plugin](./guides/contribute-services.md) to add a
|
|
152
|
+
`layer` to the plugin above.
|