@smthrs/plugin 0.0.0-stage → 1.0.0-rc.3
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 +86 -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 +160 -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 +343 -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 +19 -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 +178 -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 +367 -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 +19 -0
- package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
- package/dist/esm/internal/snapshotCacheEnvironment.js +63 -0
- package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
- package/docs/README.md +131 -0
- package/docs/api.md +522 -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 +71 -0
- package/docs/quickstart.md +151 -0
- package/docs/troubleshooting.md +179 -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 +254 -0
- package/src/Resolve.ts +535 -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 +77 -0
package/src/Resolve.ts
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded plugin resolution: admit, filter, validate, order, and snapshot.
|
|
3
|
+
*
|
|
4
|
+
* The resulting catalog owns every record it exposes. Dispatch never reads a
|
|
5
|
+
* caller-owned map, plugin record, hook object, config value, or cache identity.
|
|
6
|
+
*
|
|
7
|
+
* @since 1.0.0-rc.0
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as ImmutableMap from "@smthrs/canonical/ReadonlyMap"
|
|
11
|
+
import type { Action } from "@smthrs/flow"
|
|
12
|
+
import * as Effect from "effect/Effect"
|
|
13
|
+
import * as Layer from "effect/Layer"
|
|
14
|
+
import type { FlowsConfig } from "./Config.ts"
|
|
15
|
+
import * as Config from "./Config.ts"
|
|
16
|
+
import type { HookCatalog, HookKind, HookObject } from "./Hooks.ts"
|
|
17
|
+
import { engineHooks, handlerOf, orderOf } from "./Hooks.ts"
|
|
18
|
+
import type { FlowsHooks } from "./index.ts"
|
|
19
|
+
import * as Boundary from "./internal/Boundary.ts"
|
|
20
|
+
import { mergePluginLayers } from "./internal/mergePluginLayers.ts"
|
|
21
|
+
import { snapshotCacheEnvironment } from "./internal/snapshotCacheEnvironment.ts"
|
|
22
|
+
import type { Apply, FlowsPlugin, PluginInput } from "./Plugin.ts"
|
|
23
|
+
import { PluginError, type PluginErrorCode } from "./PluginError.ts"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maximum nested preset-array depth accepted at startup.
|
|
27
|
+
*
|
|
28
|
+
* @category limits
|
|
29
|
+
* @since 1.0.0-rc.0
|
|
30
|
+
*/
|
|
31
|
+
export const maximumPluginDepth = 64
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Maximum plugins accepted by one kernel.
|
|
35
|
+
*
|
|
36
|
+
* @category limits
|
|
37
|
+
* @since 1.0.0-rc.0
|
|
38
|
+
*/
|
|
39
|
+
export const maximumPlugins = 256
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Maximum aggregate hook handlers accepted by one kernel.
|
|
43
|
+
*
|
|
44
|
+
* @category limits
|
|
45
|
+
* @since 1.0.0-rc.0
|
|
46
|
+
*/
|
|
47
|
+
export const maximumHandlers = 1_024
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Maximum input nodes, including arrays and omitted entries.
|
|
51
|
+
*
|
|
52
|
+
* @category limits
|
|
53
|
+
* @since 1.0.0-rc.0
|
|
54
|
+
*/
|
|
55
|
+
export const maximumPluginInputNodes = 4_096
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Maximum UTF-16 length of one plugin or hook name.
|
|
59
|
+
*
|
|
60
|
+
* @category limits
|
|
61
|
+
* @since 1.0.0-rc.0
|
|
62
|
+
*/
|
|
63
|
+
export const maximumPluginNameLength = 256
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Default fiber bound for parallel observers.
|
|
67
|
+
*
|
|
68
|
+
* @category limits
|
|
69
|
+
* @since 1.0.0-rc.0
|
|
70
|
+
*/
|
|
71
|
+
export const defaultParallelConcurrency = 16
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Maximum configurable parallel-observer bound.
|
|
75
|
+
*
|
|
76
|
+
* @category limits
|
|
77
|
+
* @since 1.0.0-rc.0
|
|
78
|
+
*/
|
|
79
|
+
export const maximumParallelConcurrency = 256
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One resolved handler with immutable attribution.
|
|
83
|
+
*
|
|
84
|
+
* @category models
|
|
85
|
+
* @since 1.0.0-rc.0
|
|
86
|
+
*/
|
|
87
|
+
export interface HandlerRecord {
|
|
88
|
+
readonly plugin: string
|
|
89
|
+
readonly hook: string
|
|
90
|
+
readonly handler: (...args: Array<any>) => unknown
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The immutable output of plugin resolution.
|
|
95
|
+
*
|
|
96
|
+
* @category models
|
|
97
|
+
* @since 1.0.0-rc.0
|
|
98
|
+
*/
|
|
99
|
+
export interface Resolved<H = FlowsHooks> {
|
|
100
|
+
readonly plugins: ReadonlyArray<FlowsPlugin<H>>
|
|
101
|
+
readonly handlers: ReadonlyMap<string, ReadonlyArray<HandlerRecord>>
|
|
102
|
+
/** The frozen runtime catalog this resolution admitted. Dispatch refuses a method whose kind differs from it. */
|
|
103
|
+
readonly kinds: Readonly<Record<string, HookKind>>
|
|
104
|
+
readonly parallelConcurrency: number
|
|
105
|
+
readonly cacheEnvironment?: Action.CacheEnvironment | undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Options for {@link resolve}.
|
|
110
|
+
*
|
|
111
|
+
* @category models
|
|
112
|
+
* @since 1.0.0-rc.0
|
|
113
|
+
*/
|
|
114
|
+
export interface Options<H = FlowsHooks> {
|
|
115
|
+
/** Pre-resolution config tested by `apply` predicates. */
|
|
116
|
+
readonly config?: FlowsConfig | undefined
|
|
117
|
+
/** Host whose literal `apply` selectors are active. Defaults to `"engine"`. */
|
|
118
|
+
readonly target?: "engine" | "harness" | undefined
|
|
119
|
+
/**
|
|
120
|
+
* Hook names recognized by this host, each with the kind `H` declares for
|
|
121
|
+
* it. The shared `config` and `configResolved` hooks are fixed to
|
|
122
|
+
* `waterfall` and `parallel`; another kind fails with `hook_kind_mismatch`.
|
|
123
|
+
*/
|
|
124
|
+
readonly hooks?: HookCatalog<H> | undefined
|
|
125
|
+
/** Complete composition identity for sealed activity keys. Requires every selected plugin to have a version. */
|
|
126
|
+
readonly cacheEnvironment?: Action.CacheEnvironment | undefined
|
|
127
|
+
/** Maximum observers run at once. Defaults to 16 and cannot exceed 256. */
|
|
128
|
+
readonly parallelConcurrency?: number | undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface RawOptions {
|
|
132
|
+
readonly config: unknown
|
|
133
|
+
readonly target: unknown
|
|
134
|
+
readonly hooks: unknown
|
|
135
|
+
readonly cacheEnvironment: unknown
|
|
136
|
+
readonly parallelConcurrency: unknown
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const failure = (
|
|
140
|
+
code: PluginErrorCode,
|
|
141
|
+
message: string,
|
|
142
|
+
path: string,
|
|
143
|
+
fields: { readonly plugin?: string; readonly hook?: string } = {}
|
|
144
|
+
): PluginError => new PluginError({ code, message, path, ...fields })
|
|
145
|
+
|
|
146
|
+
const invalidPlugin = (
|
|
147
|
+
path: string,
|
|
148
|
+
complaint: string,
|
|
149
|
+
fields: { readonly plugin?: string; readonly hook?: string } = {}
|
|
150
|
+
): PluginError => failure("invalid_plugin", `plugin input ${complaint}`, path, fields)
|
|
151
|
+
|
|
152
|
+
// Bound before quoting or scanning caller-controlled keys. Keep Boundary's
|
|
153
|
+
// identifier/dot and JSON-quoted/bracket path convention.
|
|
154
|
+
const diagnosticKey = (key: string): string => key.length > 64 ? `${key.slice(0, 64)}...` : key
|
|
155
|
+
|
|
156
|
+
const childPath = (path: string, key: string): string => {
|
|
157
|
+
const bounded = diagnosticKey(key)
|
|
158
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(bounded) ? `${path}.${bounded}` : `${path}[${JSON.stringify(bounded)}]`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const ownData = (
|
|
162
|
+
input: unknown,
|
|
163
|
+
path: string,
|
|
164
|
+
allowed?: ReadonlySet<string>
|
|
165
|
+
): Readonly<Record<string, unknown>> => {
|
|
166
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
167
|
+
throw invalidPlugin(path, "must be an ordinary record")
|
|
168
|
+
}
|
|
169
|
+
const prototype = Object.getPrototypeOf(input)
|
|
170
|
+
if (prototype !== Object.prototype && prototype !== null) throw invalidPlugin(path, "must be an ordinary record")
|
|
171
|
+
const output: Record<string, unknown> = {}
|
|
172
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
173
|
+
if (typeof key !== "string") throw invalidPlugin(path, "must not contain symbol keys")
|
|
174
|
+
if (allowed !== undefined && !allowed.has(key)) {
|
|
175
|
+
throw invalidPlugin(childPath(path, key), "contains an unknown property")
|
|
176
|
+
}
|
|
177
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key)
|
|
178
|
+
if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
|
|
179
|
+
throw invalidPlugin(childPath(path, key), "must be an enumerable data property")
|
|
180
|
+
}
|
|
181
|
+
Object.defineProperty(output, key, { value: descriptor.value, enumerable: true })
|
|
182
|
+
}
|
|
183
|
+
return output
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const snapshotOptions = (input: unknown): RawOptions => {
|
|
187
|
+
const values = ownData(
|
|
188
|
+
input,
|
|
189
|
+
"$options",
|
|
190
|
+
new Set(["config", "target", "hooks", "cacheEnvironment", "parallelConcurrency"])
|
|
191
|
+
)
|
|
192
|
+
return {
|
|
193
|
+
config: values.config,
|
|
194
|
+
target: values.target,
|
|
195
|
+
hooks: values.hooks,
|
|
196
|
+
cacheEnvironment: values.cacheEnvironment,
|
|
197
|
+
parallelConcurrency: values.parallelConcurrency
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const validName = (value: unknown): value is string =>
|
|
202
|
+
typeof value === "string" && value.length > 0 && value.length <= maximumPluginNameLength &&
|
|
203
|
+
Boundary.isWellFormedText(value) && !/^\s*$/u.test(value) && !/\p{Cc}/u.test(value)
|
|
204
|
+
|
|
205
|
+
const snapshotCatalog = (input: unknown): Readonly<Record<string, HookKind>> => {
|
|
206
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
207
|
+
throw invalidPlugin("$options.hooks", "must be an ordinary hook catalog")
|
|
208
|
+
}
|
|
209
|
+
const prototype = Object.getPrototypeOf(input)
|
|
210
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
211
|
+
throw invalidPlugin("$options.hooks", "must be an ordinary hook catalog")
|
|
212
|
+
}
|
|
213
|
+
const output: Record<string, HookKind> = {}
|
|
214
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
215
|
+
if (typeof key !== "string" || !validName(key)) {
|
|
216
|
+
throw invalidPlugin("$options.hooks", "contains an invalid hook name")
|
|
217
|
+
}
|
|
218
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key)
|
|
219
|
+
if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
|
|
220
|
+
throw invalidPlugin(childPath("$options.hooks", key), "must be an enumerable data property")
|
|
221
|
+
}
|
|
222
|
+
if (!(["sequential", "parallel", "first", "waterfall"] as ReadonlyArray<unknown>).includes(descriptor.value)) {
|
|
223
|
+
throw invalidPlugin(childPath("$options.hooks", key), "contains an invalid hook kind")
|
|
224
|
+
}
|
|
225
|
+
Object.defineProperty(output, key, { value: descriptor.value, enumerable: true })
|
|
226
|
+
}
|
|
227
|
+
for (const hook of Object.keys(engineHooks) as ReadonlyArray<keyof typeof engineHooks>) {
|
|
228
|
+
if (Object.hasOwn(output, hook) && output[hook] !== engineHooks[hook]) {
|
|
229
|
+
throw failure(
|
|
230
|
+
"hook_kind_mismatch",
|
|
231
|
+
`hook catalog declares shared hook ${JSON.stringify(hook)} as ${
|
|
232
|
+
JSON.stringify(output[hook])
|
|
233
|
+
}, the kernel dispatches it as ${JSON.stringify(engineHooks[hook])}`,
|
|
234
|
+
childPath("$options.hooks", hook),
|
|
235
|
+
{ hook }
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return Object.freeze(output)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const snapshotHook = (
|
|
243
|
+
entry: unknown,
|
|
244
|
+
path: string,
|
|
245
|
+
plugin: string,
|
|
246
|
+
hook: string
|
|
247
|
+
): ((...args: Array<any>) => unknown) | HookObject<(...args: Array<any>) => unknown> => {
|
|
248
|
+
if (typeof entry === "function") return entry as (...args: Array<any>) => unknown
|
|
249
|
+
if (entry === undefined) {
|
|
250
|
+
throw invalidPlugin(path, "must be a function or a hook object", { plugin, hook })
|
|
251
|
+
}
|
|
252
|
+
const values = ownData(entry, path, new Set(["order", "handler"]))
|
|
253
|
+
if (typeof values.handler !== "function") {
|
|
254
|
+
throw invalidPlugin(`${path}.handler`, "must be a function", { plugin, hook })
|
|
255
|
+
}
|
|
256
|
+
if (values.order !== undefined && values.order !== "pre" && values.order !== "post") {
|
|
257
|
+
throw invalidPlugin(`${path}.order`, "must be pre or post", { plugin, hook })
|
|
258
|
+
}
|
|
259
|
+
return Object.freeze({
|
|
260
|
+
...(values.order === undefined ? {} : { order: values.order }),
|
|
261
|
+
handler: values.handler
|
|
262
|
+
}) as HookObject<(...args: Array<any>) => unknown>
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const snapshotPlugin = <H>(
|
|
266
|
+
input: unknown,
|
|
267
|
+
path: string
|
|
268
|
+
): FlowsPlugin<H> => {
|
|
269
|
+
const values = ownData(input, path, new Set(["name", "version", "enforce", "apply", "layer", "hooks"]))
|
|
270
|
+
if (!validName(values.name)) throw invalidPlugin(`${path}.name`, "requires a bounded non-empty name")
|
|
271
|
+
const name = values.name
|
|
272
|
+
if (values.version !== undefined && !validName(values.version)) {
|
|
273
|
+
throw invalidPlugin(`${path}.version`, "requires a bounded non-empty version", { plugin: name })
|
|
274
|
+
}
|
|
275
|
+
if (values.enforce !== undefined && values.enforce !== "pre" && values.enforce !== "post") {
|
|
276
|
+
throw invalidPlugin(`${path}.enforce`, "must be pre or post", { plugin: name })
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
values.apply !== undefined && values.apply !== "engine" && values.apply !== "harness" &&
|
|
280
|
+
typeof values.apply !== "function"
|
|
281
|
+
) throw invalidPlugin(`${path}.apply`, "must be engine, harness, or a predicate", { plugin: name })
|
|
282
|
+
if (values.layer !== undefined && !Layer.isLayer(values.layer)) {
|
|
283
|
+
throw invalidPlugin(`${path}.layer`, "must be an Effect Layer", { plugin: name })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let hooks: Readonly<Record<string, unknown>> | undefined
|
|
287
|
+
if (values.hooks !== undefined) {
|
|
288
|
+
const rawHooks = ownData(values.hooks, `${path}.hooks`)
|
|
289
|
+
const snapshot: Record<string, unknown> = Object.create(null)
|
|
290
|
+
for (const [index, hook] of Object.keys(rawHooks).entries()) {
|
|
291
|
+
if (!validName(hook)) {
|
|
292
|
+
throw invalidPlugin(`${path}.hooks[${index}]`, "contains an invalid hook name")
|
|
293
|
+
}
|
|
294
|
+
const entry = rawHooks[hook]
|
|
295
|
+
Object.defineProperty(snapshot, hook, {
|
|
296
|
+
value: snapshotHook(entry, childPath(`${path}.hooks`, hook), name, hook),
|
|
297
|
+
enumerable: true
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
hooks = Object.freeze(snapshot)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return Object.freeze({
|
|
304
|
+
name,
|
|
305
|
+
...(values.version === undefined ? {} : { version: values.version }),
|
|
306
|
+
...(values.enforce === undefined ? {} : { enforce: values.enforce }),
|
|
307
|
+
...(values.apply === undefined ? {} : { apply: values.apply as Apply }),
|
|
308
|
+
...(values.layer === undefined ? {} : { layer: values.layer as Layer.Layer<never, any, any> }),
|
|
309
|
+
...(hooks === undefined ? {} : { hooks: hooks as Partial<H> })
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
interface AdmittedPlugin<H> {
|
|
314
|
+
readonly plugin: FlowsPlugin<H>
|
|
315
|
+
readonly path: string
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const flatten = <H>(input: unknown): ReadonlyArray<AdmittedPlugin<H>> => {
|
|
319
|
+
const output: Array<AdmittedPlugin<H>> = []
|
|
320
|
+
const arrays = new WeakSet<object>()
|
|
321
|
+
const stack: Array<{ readonly value: unknown; readonly path: string; readonly depth: number }> = [
|
|
322
|
+
{ value: input, path: "$", depth: 0 }
|
|
323
|
+
]
|
|
324
|
+
let nodes = 0
|
|
325
|
+
while (stack.length > 0) {
|
|
326
|
+
const current = stack.pop()!
|
|
327
|
+
nodes += 1
|
|
328
|
+
if (current.value === false || current.value === null || current.value === undefined) continue
|
|
329
|
+
if (Array.isArray(current.value)) {
|
|
330
|
+
if (current.depth >= maximumPluginDepth) {
|
|
331
|
+
throw failure("resource_limit", `plugin input exceeds the depth limit of ${maximumPluginDepth}`, current.path)
|
|
332
|
+
}
|
|
333
|
+
if (arrays.has(current.value)) throw invalidPlugin(current.path, "contains a cycle or repeated preset array")
|
|
334
|
+
arrays.add(current.value)
|
|
335
|
+
const length = current.value.length
|
|
336
|
+
// Every queued frame and array element costs a node, including omitted
|
|
337
|
+
// entries. Refuse before enumerating keys, descriptors, or child frames.
|
|
338
|
+
if (length > maximumPluginInputNodes - nodes - stack.length) {
|
|
339
|
+
throw failure("resource_limit", `plugin input exceeds the ${maximumPluginInputNodes}-node limit`, current.path)
|
|
340
|
+
}
|
|
341
|
+
const keys = Reflect.ownKeys(current.value)
|
|
342
|
+
if (keys.length !== length + 1 || !keys.includes("length")) {
|
|
343
|
+
throw invalidPlugin(current.path, "must be a dense preset array with no extra properties")
|
|
344
|
+
}
|
|
345
|
+
for (let index = length - 1; index >= 0; index--) {
|
|
346
|
+
const descriptor = Object.getOwnPropertyDescriptor(current.value, String(index))
|
|
347
|
+
if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable) {
|
|
348
|
+
throw invalidPlugin(`${current.path}[${index}]`, "must be an enumerable data property")
|
|
349
|
+
}
|
|
350
|
+
stack.push({ value: descriptor.value, path: `${current.path}[${index}]`, depth: current.depth + 1 })
|
|
351
|
+
}
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
output.push(Object.freeze({ plugin: snapshotPlugin<H>(current.value, current.path), path: current.path }))
|
|
355
|
+
if (output.length > maximumPlugins) {
|
|
356
|
+
throw failure("resource_limit", `plugin input exceeds the ${maximumPlugins}-plugin limit`, current.path)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return output
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const included = <H>(
|
|
363
|
+
plugin: FlowsPlugin<H>,
|
|
364
|
+
config: FlowsConfig,
|
|
365
|
+
target: "engine" | "harness"
|
|
366
|
+
): Effect.Effect<boolean, PluginError> => {
|
|
367
|
+
const apply = plugin.apply
|
|
368
|
+
if (apply === undefined) return Effect.succeed(true)
|
|
369
|
+
if (typeof apply !== "function") return Effect.succeed(apply === target)
|
|
370
|
+
return Effect.try({
|
|
371
|
+
try: () => apply(config),
|
|
372
|
+
catch: () =>
|
|
373
|
+
failure("apply_failed", `plugin "${plugin.name}" apply predicate failed`, "$.apply", {
|
|
374
|
+
plugin: plugin.name
|
|
375
|
+
})
|
|
376
|
+
}).pipe(
|
|
377
|
+
Effect.flatMap((result) =>
|
|
378
|
+
typeof result === "boolean"
|
|
379
|
+
? Effect.succeed(result)
|
|
380
|
+
: Effect.fail(invalidPlugin("$.apply", "predicate must return a boolean", { plugin: plugin.name }))
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const rank = (enforce: "pre" | "post" | undefined): number => (enforce === "pre" ? 0 : enforce === "post" ? 2 : 1)
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Resolves a plugin preset into an immutable, resource-bounded catalog.
|
|
389
|
+
*
|
|
390
|
+
* @category constructors
|
|
391
|
+
* @since 1.0.0-rc.0
|
|
392
|
+
*/
|
|
393
|
+
export const resolve = <H = FlowsHooks>(
|
|
394
|
+
input: PluginInput<NoInfer<H>>,
|
|
395
|
+
options: Options<NoInfer<H>> = {},
|
|
396
|
+
configOverride?: FlowsConfig
|
|
397
|
+
): Effect.Effect<Resolved<H>, PluginError> =>
|
|
398
|
+
Effect.gen(function*() {
|
|
399
|
+
const raw = yield* Effect.try({
|
|
400
|
+
try: () => snapshotOptions(options),
|
|
401
|
+
catch: (cause) =>
|
|
402
|
+
cause instanceof PluginError
|
|
403
|
+
? cause
|
|
404
|
+
: invalidPlugin("$options", "could not be inspected without executing user code")
|
|
405
|
+
})
|
|
406
|
+
if (configOverride !== undefined && raw.config !== undefined) {
|
|
407
|
+
return yield* Effect.fail(invalidPlugin(
|
|
408
|
+
"$options.config",
|
|
409
|
+
"cannot declare config in options alongside the positional override"
|
|
410
|
+
))
|
|
411
|
+
}
|
|
412
|
+
if (raw.target !== undefined && raw.target !== "engine" && raw.target !== "harness") {
|
|
413
|
+
return yield* Effect.fail(invalidPlugin("$options.target", "must be engine or harness"))
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
raw.parallelConcurrency !== undefined &&
|
|
417
|
+
(typeof raw.parallelConcurrency !== "number" || !Number.isSafeInteger(raw.parallelConcurrency) ||
|
|
418
|
+
raw.parallelConcurrency <= 0 ||
|
|
419
|
+
raw.parallelConcurrency > maximumParallelConcurrency)
|
|
420
|
+
) {
|
|
421
|
+
return yield* Effect.fail(failure(
|
|
422
|
+
"resource_limit",
|
|
423
|
+
`parallel concurrency must be between 1 and ${maximumParallelConcurrency}`,
|
|
424
|
+
"$options.parallelConcurrency"
|
|
425
|
+
))
|
|
426
|
+
}
|
|
427
|
+
const parallelConcurrency = raw.parallelConcurrency === undefined
|
|
428
|
+
? defaultParallelConcurrency
|
|
429
|
+
: raw.parallelConcurrency
|
|
430
|
+
const config = yield* Config.snapshot(configOverride ?? raw.config ?? {})
|
|
431
|
+
const known = yield* Effect.try({
|
|
432
|
+
try: () => snapshotCatalog(raw.hooks ?? engineHooks),
|
|
433
|
+
catch: (cause) =>
|
|
434
|
+
cause instanceof PluginError
|
|
435
|
+
? cause
|
|
436
|
+
: invalidPlugin("$options.hooks", "could not be inspected without executing user code")
|
|
437
|
+
})
|
|
438
|
+
const admitted = yield* Effect.try({
|
|
439
|
+
try: () => flatten<H>(input),
|
|
440
|
+
catch: (cause) =>
|
|
441
|
+
cause instanceof PluginError
|
|
442
|
+
? cause
|
|
443
|
+
: invalidPlugin("$", "could not be inspected without executing user code")
|
|
444
|
+
})
|
|
445
|
+
const target = raw.target ?? "engine"
|
|
446
|
+
const selected = yield* Effect.filter(admitted, ({ plugin }) => included(plugin, config, target))
|
|
447
|
+
|
|
448
|
+
let handlerCount = 0
|
|
449
|
+
for (const { path, plugin } of selected) {
|
|
450
|
+
const pluginHooks = plugin.hooks as Readonly<Record<string, unknown>> | undefined
|
|
451
|
+
if (pluginHooks === undefined) continue
|
|
452
|
+
for (const hook of Object.keys(pluginHooks)) {
|
|
453
|
+
if (!Object.hasOwn(known, hook)) {
|
|
454
|
+
return yield* Effect.fail(failure(
|
|
455
|
+
"unknown_hook",
|
|
456
|
+
`plugin ${JSON.stringify(plugin.name)} declares unknown hook ${JSON.stringify(diagnosticKey(hook))}`,
|
|
457
|
+
childPath(`${path}.hooks`, hook),
|
|
458
|
+
{ plugin: plugin.name, hook }
|
|
459
|
+
))
|
|
460
|
+
}
|
|
461
|
+
handlerCount += 1
|
|
462
|
+
if (handlerCount > maximumHandlers) {
|
|
463
|
+
return yield* Effect.fail(failure(
|
|
464
|
+
"resource_limit",
|
|
465
|
+
`plugin handlers exceed the limit of ${maximumHandlers}`,
|
|
466
|
+
childPath(`${path}.hooks`, hook)
|
|
467
|
+
))
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const seen = new Set<string>()
|
|
473
|
+
for (const { plugin } of selected) {
|
|
474
|
+
if (seen.has(plugin.name)) {
|
|
475
|
+
return yield* Effect.fail(failure(
|
|
476
|
+
"duplicate_name",
|
|
477
|
+
`duplicate plugin name "${plugin.name}"`,
|
|
478
|
+
"$.name",
|
|
479
|
+
{ plugin: plugin.name }
|
|
480
|
+
))
|
|
481
|
+
}
|
|
482
|
+
seen.add(plugin.name)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const plugins = Object.freeze(
|
|
486
|
+
selected
|
|
487
|
+
.map(({ plugin }, index) => ({ plugin, index }))
|
|
488
|
+
.sort((left, right) => rank(left.plugin.enforce) - rank(right.plugin.enforce) || left.index - right.index)
|
|
489
|
+
.map(({ plugin }) => plugin)
|
|
490
|
+
)
|
|
491
|
+
const entries: Array<readonly [string, ReadonlyArray<HandlerRecord>]> = []
|
|
492
|
+
for (const hook of Object.keys(known)) {
|
|
493
|
+
const records: Array<{
|
|
494
|
+
readonly record: HandlerRecord
|
|
495
|
+
readonly enforceRank: number
|
|
496
|
+
readonly orderRank: number
|
|
497
|
+
readonly index: number
|
|
498
|
+
}> = []
|
|
499
|
+
for (const [index, plugin] of plugins.entries()) {
|
|
500
|
+
const pluginHooks = plugin.hooks as Readonly<Record<string, unknown>> | undefined
|
|
501
|
+
if (pluginHooks === undefined || !Object.hasOwn(pluginHooks, hook)) continue
|
|
502
|
+
const entry = pluginHooks[hook]
|
|
503
|
+
const record = Object.freeze({ plugin: plugin.name, hook, handler: handlerOf(entry) })
|
|
504
|
+
records.push({ record, enforceRank: rank(plugin.enforce), orderRank: rank(orderOf(entry)), index })
|
|
505
|
+
}
|
|
506
|
+
// Vite's rule, verbatim: the per-hook `order` is the outer partition and
|
|
507
|
+
// the enforce-sorted plugin list supplies the stable order inside it, so a
|
|
508
|
+
// hook marked `order: "pre"` runs ahead of every normal-order handler even
|
|
509
|
+
// when its plugin is `enforce: "post"`.
|
|
510
|
+
const ordered = records
|
|
511
|
+
.sort((left, right) =>
|
|
512
|
+
left.orderRank - right.orderRank || left.enforceRank - right.enforceRank || left.index - right.index
|
|
513
|
+
)
|
|
514
|
+
.map(({ record }) => record)
|
|
515
|
+
if (ordered.length > 0) entries.push([hook, Object.freeze(ordered)])
|
|
516
|
+
}
|
|
517
|
+
const cacheEnvironment = yield* snapshotCacheEnvironment(raw.cacheEnvironment, plugins)
|
|
518
|
+
return Object.freeze({
|
|
519
|
+
plugins,
|
|
520
|
+
handlers: ImmutableMap.make(entries),
|
|
521
|
+
kinds: known,
|
|
522
|
+
parallelConcurrency,
|
|
523
|
+
...(cacheEnvironment === undefined ? {} : { cacheEnvironment })
|
|
524
|
+
})
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Merges every resolved plugin layer left-to-right and supplies the detached
|
|
529
|
+
* cache environment captured during resolution.
|
|
530
|
+
*
|
|
531
|
+
* @category combinators
|
|
532
|
+
* @since 1.0.0-rc.0
|
|
533
|
+
*/
|
|
534
|
+
export const layer = <H>(resolved: Resolved<H>): Layer.Layer<any, PluginError, any> =>
|
|
535
|
+
mergePluginLayers(resolved.plugins, resolved.cacheEnvironment)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded cell-host plugin kernel: typed hooks, resolution and ordering,
|
|
3
|
+
* and the config pipeline.
|
|
4
|
+
*
|
|
5
|
+
* The public package contract is documented at
|
|
6
|
+
* {@link https://smithers.sh/docs/reference/api/plugin}, with the shipped host catalog in
|
|
7
|
+
* `packages/smithers/agent/src/CellPlugin.ts`. Durable
|
|
8
|
+
* engine extension remains dependency injection; this package does not expose
|
|
9
|
+
* an engine-wide lifecycle registry.
|
|
10
|
+
*
|
|
11
|
+
* @since 1.0.0-rc.0
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type * as Effect from "effect/Effect"
|
|
15
|
+
import type { FlowsConfig, ResolvedConfig } from "./Config.ts"
|
|
16
|
+
import type { ParallelHook, WaterfallHook } from "./Hooks.ts"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The shared kernel's base hook catalog.
|
|
20
|
+
*
|
|
21
|
+
* Declared here, in the package entry point, so that the documented
|
|
22
|
+
* augmentation specifier works: an interface can only be augmented in the
|
|
23
|
+
* module that declares it:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* declare module "@smthrs/plugin" {
|
|
27
|
+
* interface FlowsHooks {
|
|
28
|
+
* toolCall: SequentialHook<(ctx: ToolCallContext) => Effect.Effect<Option.Option<ToolOverride>>>
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* Closed for dispatch, open for augmentation: the kernel dispatches only the
|
|
34
|
+
* config lifecycle below; a host supplies and dispatches its own catalog over
|
|
35
|
+
* the same augmented interface.
|
|
36
|
+
*
|
|
37
|
+
* Both startup hooks are context-free: `Kernel.make` runs them before any
|
|
38
|
+
* plugin layer exists and supplies no services of its own, so a handler that
|
|
39
|
+
* needs a service provides it inside the hook. A host whose startup hooks do
|
|
40
|
+
* require services declares a separate hook interface, and `Kernel.make` then
|
|
41
|
+
* surfaces those requirements as `Kernel.StartupContext<H>`.
|
|
42
|
+
*
|
|
43
|
+
* @category models
|
|
44
|
+
* @since 1.0.0-rc.0
|
|
45
|
+
*/
|
|
46
|
+
export interface FlowsHooks {
|
|
47
|
+
readonly config: WaterfallHook<(config: FlowsConfig) => Effect.Effect<Partial<FlowsConfig> | void, any, never>>
|
|
48
|
+
readonly configResolved: ParallelHook<(config: ResolvedConfig) => Effect.Effect<void, any, never>>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @since 1.0.0-rc.0
|
|
53
|
+
* @category config
|
|
54
|
+
*/
|
|
55
|
+
export * as Config from "./Config.ts"
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @since 1.0.0-rc.0
|
|
59
|
+
* @category hooks
|
|
60
|
+
*/
|
|
61
|
+
export * from "./Hooks.ts"
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @since 1.0.0-rc.0
|
|
65
|
+
* @category startup
|
|
66
|
+
*/
|
|
67
|
+
export * as Kernel from "./Kernel.ts"
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @since 1.0.0-rc.0
|
|
71
|
+
* @category models
|
|
72
|
+
*/
|
|
73
|
+
export * from "./Plugin.ts"
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @since 1.0.0-rc.0
|
|
77
|
+
* @category errors
|
|
78
|
+
*/
|
|
79
|
+
export * from "./PluginError.ts"
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @since 1.0.0-rc.0
|
|
83
|
+
* @category dispatch
|
|
84
|
+
*/
|
|
85
|
+
export * as Plugins from "./Plugins.ts"
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @since 1.0.0-rc.0
|
|
89
|
+
* @category resolution
|
|
90
|
+
*/
|
|
91
|
+
export * as Resolve from "./Resolve.ts"
|