@smthrs/plugin 0.0.0-stage → 1.0.0-rc.4

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.
Files changed (118) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +86 -2
  4. package/dist/cjs/Config.d.ts +129 -0
  5. package/dist/cjs/Config.d.ts.map +1 -0
  6. package/dist/cjs/Config.js +115 -0
  7. package/dist/cjs/Config.js.map +7 -0
  8. package/dist/cjs/Hooks.d.ts +176 -0
  9. package/dist/cjs/Hooks.d.ts.map +1 -0
  10. package/dist/cjs/Hooks.js +34 -0
  11. package/dist/cjs/Hooks.js.map +7 -0
  12. package/dist/cjs/Kernel.d.ts +81 -0
  13. package/dist/cjs/Kernel.d.ts.map +1 -0
  14. package/dist/cjs/Kernel.js +57 -0
  15. package/dist/cjs/Kernel.js.map +7 -0
  16. package/dist/cjs/Plugin.d.ts +71 -0
  17. package/dist/cjs/Plugin.d.ts.map +1 -0
  18. package/dist/cjs/Plugin.js +25 -0
  19. package/dist/cjs/Plugin.js.map +7 -0
  20. package/dist/cjs/PluginError.d.ts +50 -0
  21. package/dist/cjs/PluginError.d.ts.map +1 -0
  22. package/dist/cjs/PluginError.js +58 -0
  23. package/dist/cjs/PluginError.js.map +7 -0
  24. package/dist/cjs/Plugins.d.ts +97 -0
  25. package/dist/cjs/Plugins.d.ts.map +1 -0
  26. package/dist/cjs/Plugins.js +160 -0
  27. package/dist/cjs/Plugins.js.map +7 -0
  28. package/dist/cjs/Resolve.d.ts +128 -0
  29. package/dist/cjs/Resolve.d.ts.map +1 -0
  30. package/dist/cjs/Resolve.js +343 -0
  31. package/dist/cjs/Resolve.js.map +7 -0
  32. package/dist/cjs/index.d.ts +83 -0
  33. package/dist/cjs/index.d.ts.map +1 -0
  34. package/dist/cjs/index.js +45 -0
  35. package/dist/cjs/index.js.map +7 -0
  36. package/dist/cjs/internal/Boundary.d.ts +96 -0
  37. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  38. package/dist/cjs/internal/Boundary.js +129 -0
  39. package/dist/cjs/internal/Boundary.js.map +7 -0
  40. package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
  41. package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
  42. package/dist/cjs/internal/mergePluginLayers.js +57 -0
  43. package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
  44. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +19 -0
  45. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  46. package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
  47. package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
  48. package/dist/cjs/package.json +1 -0
  49. package/dist/esm/Config.d.ts +129 -0
  50. package/dist/esm/Config.d.ts.map +1 -0
  51. package/dist/esm/Config.js +160 -0
  52. package/dist/esm/Config.js.map +1 -0
  53. package/dist/esm/Hooks.d.ts +176 -0
  54. package/dist/esm/Hooks.d.ts.map +1 -0
  55. package/dist/esm/Hooks.js +56 -0
  56. package/dist/esm/Hooks.js.map +1 -0
  57. package/dist/esm/Kernel.d.ts +81 -0
  58. package/dist/esm/Kernel.d.ts.map +1 -0
  59. package/dist/esm/Kernel.js +58 -0
  60. package/dist/esm/Kernel.js.map +1 -0
  61. package/dist/esm/Plugin.d.ts +71 -0
  62. package/dist/esm/Plugin.d.ts.map +1 -0
  63. package/dist/esm/Plugin.js +20 -0
  64. package/dist/esm/Plugin.js.map +1 -0
  65. package/dist/esm/PluginError.d.ts +50 -0
  66. package/dist/esm/PluginError.d.ts.map +1 -0
  67. package/dist/esm/PluginError.js +53 -0
  68. package/dist/esm/PluginError.js.map +1 -0
  69. package/dist/esm/Plugins.d.ts +97 -0
  70. package/dist/esm/Plugins.d.ts.map +1 -0
  71. package/dist/esm/Plugins.js +178 -0
  72. package/dist/esm/Plugins.js.map +1 -0
  73. package/dist/esm/Resolve.d.ts +128 -0
  74. package/dist/esm/Resolve.d.ts.map +1 -0
  75. package/dist/esm/Resolve.js +367 -0
  76. package/dist/esm/Resolve.js.map +1 -0
  77. package/dist/esm/index.d.ts +83 -0
  78. package/dist/esm/index.d.ts.map +1 -0
  79. package/dist/esm/index.js +48 -0
  80. package/dist/esm/index.js.map +1 -0
  81. package/dist/esm/internal/Boundary.d.ts +96 -0
  82. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  83. package/dist/esm/internal/Boundary.js +162 -0
  84. package/dist/esm/internal/Boundary.js.map +1 -0
  85. package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
  86. package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
  87. package/dist/esm/internal/mergePluginLayers.js +41 -0
  88. package/dist/esm/internal/mergePluginLayers.js.map +1 -0
  89. package/dist/esm/internal/snapshotCacheEnvironment.d.ts +19 -0
  90. package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  91. package/dist/esm/internal/snapshotCacheEnvironment.js +63 -0
  92. package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
  93. package/docs/README.md +131 -0
  94. package/docs/api.md +522 -0
  95. package/docs/concepts/configuration.md +111 -0
  96. package/docs/concepts/hook-kinds.md +107 -0
  97. package/docs/concepts/resolution.md +132 -0
  98. package/docs/guides/cache-identity.md +106 -0
  99. package/docs/guides/contribute-services.md +84 -0
  100. package/docs/guides/host-your-own-hooks.md +129 -0
  101. package/docs/guides/order-handlers.md +76 -0
  102. package/docs/guides/select-plugins.md +72 -0
  103. package/docs/guides/testing.md +149 -0
  104. package/docs/installation.md +71 -0
  105. package/docs/quickstart.md +151 -0
  106. package/docs/troubleshooting.md +179 -0
  107. package/package.json +179 -3
  108. package/src/Config.ts +207 -0
  109. package/src/Hooks.ts +212 -0
  110. package/src/Kernel.ts +119 -0
  111. package/src/Plugin.ts +80 -0
  112. package/src/PluginError.ts +62 -0
  113. package/src/Plugins.ts +254 -0
  114. package/src/Resolve.ts +535 -0
  115. package/src/index.ts +91 -0
  116. package/src/internal/Boundary.ts +252 -0
  117. package/src/internal/mergePluginLayers.ts +57 -0
  118. package/src/internal/snapshotCacheEnvironment.ts +77 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Inert JSON admission for plugin configuration and cache identity.
3
+ *
4
+ * Reflection is descriptor-only: accessors never execute, hostile proxies are
5
+ * refused, and accepted values are detached before they are frozen.
6
+ *
7
+ * @private
8
+ * @since 1.0.0-rc.0
9
+ */
10
+
11
+ import * as BoundedJson from "@smthrs/canonical/BoundedJson"
12
+
13
+ /**
14
+ * Strict JSON value accepted by the boundary.
15
+ *
16
+ * @private
17
+ * @since 1.0.0-rc.0
18
+ */
19
+ export type Json = BoundedJson.Json
20
+
21
+ /**
22
+ * Resource bounds for one admitted tree.
23
+ *
24
+ * @private
25
+ * @since 1.0.0-rc.0
26
+ */
27
+ export type Limits = BoundedJson.StrictLimits
28
+
29
+ /**
30
+ * Successful admission.
31
+ *
32
+ * @private
33
+ * @since 1.0.0-rc.0
34
+ */
35
+ export interface AdmissionSuccess {
36
+ readonly ok: true
37
+ readonly value: Json
38
+ }
39
+
40
+ /**
41
+ * Refusal with a stable value path.
42
+ *
43
+ * @private
44
+ * @since 1.0.0-rc.0
45
+ */
46
+ export interface AdmissionFailure {
47
+ readonly ok: false
48
+ readonly path: string
49
+ readonly complaint: string
50
+ }
51
+
52
+ /**
53
+ * Result of inert JSON admission.
54
+ *
55
+ * @private
56
+ * @since 1.0.0-rc.0
57
+ */
58
+ export type Admission = AdmissionSuccess | AdmissionFailure
59
+
60
+ /**
61
+ * Default configuration bounds.
62
+ *
63
+ * @private
64
+ * @since 1.0.0-rc.0
65
+ */
66
+ export const defaultLimits: Limits = Object.freeze({
67
+ maxBytes: 1024 * 1024,
68
+ maxDepth: 64,
69
+ maxMembers: 4_096,
70
+ maxNodes: 8_192,
71
+ maxStringBytes: 64 * 1024,
72
+ maxKeyBytes: 1_024
73
+ })
74
+
75
+ /**
76
+ * Tests whether a string contains paired UTF-16 surrogate units.
77
+ *
78
+ * @private
79
+ * @since 1.0.0-rc.0
80
+ */
81
+ export const isWellFormedText = (value: string): boolean => value.isWellFormed()
82
+
83
+ const childPath = (path: string, key: string): string =>
84
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`
85
+
86
+ const encodedStringBytes = (value: string): number => BoundedJson.encodedStringBytes(value)!
87
+
88
+ // Paths contain only well-formed text and JSON-escaped keys. Count their JSON
89
+ // bytes without encoding the whole path, reserving quotes and a truncation marker.
90
+ const boundedPath = (path: string): string => {
91
+ let bytes = 2 + 3
92
+ let prefix = ""
93
+ for (const character of path) {
94
+ const point = character.codePointAt(0)!
95
+ bytes += character === "\"" || character === "\\"
96
+ ? 2
97
+ : point <= 0x7f
98
+ ? 1
99
+ : point <= 0x7ff
100
+ ? 2
101
+ : point <= 0xffff
102
+ ? 3
103
+ : 4
104
+ if (bytes > 192) return `${prefix}...`
105
+ prefix += character
106
+ }
107
+ return path
108
+ }
109
+
110
+ interface Totals {
111
+ bytes: number
112
+ members: number
113
+ nodes: number
114
+ depth: number
115
+ }
116
+
117
+ interface Entry {
118
+ readonly keyBytes: number
119
+ readonly totals: Totals
120
+ }
121
+
122
+ interface Snapshot {
123
+ readonly totals: Totals
124
+ readonly entries: Map<string, Entry>
125
+ }
126
+
127
+ // Only detached containers constructed here enter this map. Mutable caller
128
+ // objects and Object.freeze alone never establish admission.
129
+ const snapshots = new WeakMap<object, Snapshot>()
130
+
131
+ const scalarTotals = (bytes: number): Totals => ({ bytes, members: 0, nodes: 1, depth: 0 })
132
+
133
+ const total = (entries: ReadonlyMap<string, Entry>): Totals => {
134
+ const totals: Totals = { bytes: 2 + Math.max(0, entries.size - 1), members: entries.size, nodes: 1, depth: 1 }
135
+ for (const entry of entries.values()) {
136
+ totals.bytes += entry.keyBytes + entry.totals.bytes
137
+ totals.members += entry.totals.members
138
+ totals.nodes += entry.totals.nodes
139
+ totals.depth = Math.max(totals.depth, 1 + entry.totals.depth)
140
+ }
141
+ return totals
142
+ }
143
+
144
+ const remember = (value: ReadonlyArray<Json> | { readonly [key: string]: Json }): void => {
145
+ const entries = new Map<string, Entry>()
146
+ for (const [key, child] of Object.entries(value)) {
147
+ entries.set(key, {
148
+ keyBytes: Array.isArray(value) ? 0 : encodedStringBytes(key) + 1,
149
+ totals: typeof child === "object" && child !== null ?
150
+ snapshots.get(child)!.totals
151
+ : scalarTotals(typeof child === "string" ? encodedStringBytes(child) : String(child).length)
152
+ })
153
+ }
154
+ snapshots.set(value, { totals: total(entries), entries })
155
+ }
156
+
157
+ /**
158
+ * Copies one strict JSON tree under explicit resource bounds.
159
+ *
160
+ * @private
161
+ * @since 1.0.0-rc.0
162
+ */
163
+ export const admit = (input: unknown, limits: Limits = defaultLimits): Admission => {
164
+ const result = BoundedJson.admitStrict(input, limits, {
165
+ ordinaryRecords: true,
166
+ boundedText: true,
167
+ onContainer: remember
168
+ })
169
+ return result.ok ? result : {
170
+ ...result,
171
+ path: boundedPath(result.path),
172
+ complaint: result.complaint === "must be dense and have no extra properties"
173
+ ? "must be a dense array with no extra properties" :
174
+ result.complaint
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Admits only a JSON record at the root.
180
+ *
181
+ * @private
182
+ * @since 1.0.0-rc.0
183
+ */
184
+ export const record = (
185
+ input: unknown,
186
+ limits: Limits = defaultLimits
187
+ ): AdmissionSuccess | AdmissionFailure => {
188
+ const admitted = admit(input, limits)
189
+ return admitted.ok && (admitted.value === null || Array.isArray(admitted.value) || typeof admitted.value !== "object")
190
+ ? { ok: false, path: "$", complaint: "must be a JSON record" }
191
+ : admitted
192
+ }
193
+
194
+ /**
195
+ * Merges two independently admitted records, retaining unchanged subtrees.
196
+ * Both operands must come from successful admission, and the patch must be a
197
+ * fresh copy: their trees must be disjoint. Each output branch then comes from
198
+ * at most one location in either tree, preserving the no-repeated-reference
199
+ * invariant without walking retained subtrees. Limits apply to the result.
200
+ *
201
+ * @private
202
+ * @since 1.0.0-rc.0
203
+ */
204
+ export const mergeRecords = (
205
+ base: { readonly [key: string]: Json },
206
+ patch: { readonly [key: string]: Json },
207
+ limits: Limits = defaultLimits
208
+ ): Admission => {
209
+ const merge = (
210
+ left: { readonly [key: string]: Json },
211
+ right: { readonly [key: string]: Json },
212
+ path: string
213
+ ): Admission => {
214
+ const entries = new Map(snapshots.get(left)!.entries)
215
+ const result = { ...left }
216
+ for (const [key, entry] of snapshots.get(right)!.entries) {
217
+ const previous = left[key]
218
+ const next = right[key]!
219
+ if (
220
+ typeof previous === "object" && previous !== null && !Array.isArray(previous) &&
221
+ typeof next === "object" && next !== null && !Array.isArray(next)
222
+ ) {
223
+ const merged = merge(
224
+ previous as { readonly [key: string]: Json },
225
+ next as { readonly [key: string]: Json },
226
+ childPath(path, key)
227
+ )
228
+ if (!merged.ok) return merged
229
+ result[key] = merged.value
230
+ entries.set(key, { keyBytes: entry.keyBytes, totals: snapshots.get(merged.value as object)!.totals })
231
+ } else {
232
+ result[key] = next
233
+ entries.set(key, entry)
234
+ }
235
+ }
236
+ const totals = total(entries)
237
+ const complaint = totals.members > limits.maxMembers ?
238
+ `exceeds the ${limits.maxMembers}-member limit`
239
+ : totals.nodes > limits.maxNodes ?
240
+ `exceeds the ${limits.maxNodes}-node limit`
241
+ : totals.bytes > limits.maxBytes ?
242
+ `exceeds the ${limits.maxBytes}-byte limit`
243
+ : totals.depth > limits.maxDepth ?
244
+ `exceeds the depth limit of ${limits.maxDepth}`
245
+ : undefined
246
+ if (complaint !== undefined) return { ok: false, path: boundedPath(path), complaint }
247
+ Object.freeze(result)
248
+ snapshots.set(result, { totals, entries })
249
+ return { ok: true, value: result }
250
+ }
251
+ return merge(base, patch, "$")
252
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Effect layer composition for a resolved plugin list.
3
+ *
4
+ * @private
5
+ * @since 1.0.0-rc.0
6
+ */
7
+
8
+ import { Action } from "@smthrs/flow"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Layer from "effect/Layer"
11
+ import type { FlowsPlugin } from "../Plugin.ts"
12
+ import { PluginError } from "../PluginError.ts"
13
+
14
+ /**
15
+ * Merges plugin layers left-to-right, wrapping each build failure as
16
+ * `layer_failed`, then seals the cache environment: it is supplied beneath
17
+ * the plugin layers and overrides anything they output, and an absent
18
+ * declaration stays absent.
19
+ *
20
+ * @private
21
+ * @since 1.0.0-rc.0
22
+ */
23
+ export const mergePluginLayers = <H>(
24
+ plugins: ReadonlyArray<FlowsPlugin<H>>,
25
+ cacheEnvironment: Action.CacheEnvironment | undefined
26
+ ): Layer.Layer<any, PluginError, any> => {
27
+ const layers = plugins.flatMap((plugin) =>
28
+ plugin.layer
29
+ ? [
30
+ Layer.catchCause(plugin.layer, (cause) =>
31
+ Layer.effectDiscard(
32
+ Effect.fail(
33
+ new PluginError({
34
+ code: "layer_failed",
35
+ message: `plugin "${plugin.name}" failed to build its layer`,
36
+ plugin: plugin.name,
37
+ cause
38
+ })
39
+ )
40
+ )) as Layer.Layer<any, PluginError, any>
41
+ ]
42
+ : []
43
+ )
44
+ const merged = (layers.length === 0
45
+ ? Layer.empty
46
+ : layers.reduce((accumulated, next) => Layer.provideMerge(next, accumulated))) as Layer.Layer<any, PluginError, any>
47
+ // The sealed environment is provided beneath the plugin layers so they can
48
+ // read it, then merged last so it overrides any plugin output. An absent
49
+ // declaration is sealed as `undefined`, so no plugin layer can declare or
50
+ // replace the composition's cache identity.
51
+ const environment = Layer.succeed(Action.CurrentCacheEnvironment)(cacheEnvironment) as unknown as Layer.Layer<
52
+ any,
53
+ PluginError,
54
+ any
55
+ >
56
+ return Layer.merge(Layer.provideMerge(merged, environment), environment)
57
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Cache identity admission for a resolved plugin list.
3
+ *
4
+ * @private
5
+ * @since 1.0.0-rc.0
6
+ */
7
+
8
+ import { Action } from "@smthrs/flow"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Schema from "effect/Schema"
11
+ import type { FlowsPlugin } from "../Plugin.ts"
12
+ import { PluginError } from "../PluginError.ts"
13
+ import * as Boundary from "./Boundary.ts"
14
+
15
+ const escapePluginIdentityPart = (value: string): string =>
16
+ // Percent must be escaped first so literal escape-looking text remains distinct from the `@` escape added next.
17
+ value.replaceAll("%", "%25").replaceAll("@", "%40")
18
+
19
+ /**
20
+ * Admits a caller cache environment and prepends one escaped `name@version`
21
+ * identity per plugin, in resolved order. An absent input stays absent.
22
+ *
23
+ * @private
24
+ * @since 1.0.0-rc.0
25
+ */
26
+ export const snapshotCacheEnvironment = <H>(
27
+ input: unknown,
28
+ plugins: ReadonlyArray<FlowsPlugin<H>>
29
+ ): Effect.Effect<Action.CacheEnvironment | undefined, PluginError> => {
30
+ if (input === undefined) return Effect.succeed(undefined)
31
+ const pluginIdentities: Array<string> = []
32
+ for (const plugin of plugins) {
33
+ if (plugin.version === undefined) {
34
+ return Effect.fail(
35
+ new PluginError({
36
+ code: "cache_environment_invalid",
37
+ message: `cache environment requires a version for plugin "${plugin.name}"`,
38
+ path: "$.version",
39
+ plugin: plugin.name
40
+ })
41
+ )
42
+ }
43
+ pluginIdentities.push(`${escapePluginIdentityPart(plugin.name)}@${escapePluginIdentityPart(plugin.version)}`)
44
+ }
45
+ const admitted = Boundary.record(input)
46
+ if (!admitted.ok) {
47
+ return Effect.fail(
48
+ new PluginError({
49
+ code: "cache_environment_invalid",
50
+ message: `cache environment ${admitted.complaint}`,
51
+ path: `$options.cacheEnvironment${admitted.path.slice(1)}`
52
+ })
53
+ )
54
+ }
55
+ return Schema.decodeUnknownEffect(Action.CacheEnvironment)(admitted.value).pipe(
56
+ Effect.mapError(() =>
57
+ new PluginError({
58
+ code: "cache_environment_invalid",
59
+ message: "cache environment does not match the complete cache identity schema",
60
+ path: "$options.cacheEnvironment"
61
+ })
62
+ ),
63
+ Effect.map((environment) => {
64
+ const capabilities: Record<string, ReadonlyArray<string>> = {}
65
+ for (const name of Object.keys(environment.capabilities)) {
66
+ Object.defineProperty(capabilities, name, {
67
+ value: Object.freeze([...environment.capabilities[name]!]),
68
+ enumerable: true
69
+ })
70
+ }
71
+ return Object.freeze({
72
+ layers: Object.freeze([...pluginIdentities, ...environment.layers]),
73
+ capabilities: Object.freeze(capabilities)
74
+ })
75
+ })
76
+ )
77
+ }