@meith/plugin-kit 0.1.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/src/host.ts ADDED
@@ -0,0 +1,283 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { HOOKS, type HookName } from './hooks'
4
+ import type { HookContext, HookValue } from './payloads'
5
+ import type { HookRegistration, PluginContribution, PluginDefinition } from './plugin'
6
+ import type { PluginRegion, PluginRegionContext } from './regions'
7
+
8
+ export interface HostLogger {
9
+ readonly warn: (message: string, detail: Record<string, unknown>) => void
10
+ readonly error: (message: string, detail: Record<string, unknown>) => void
11
+ }
12
+
13
+ export interface PluginHostOptions {
14
+ readonly plugins: readonly PluginDefinition[]
15
+ readonly logger?: HostLogger | undefined
16
+ readonly failureThreshold?: number | undefined
17
+ readonly slowCallMs?: number | undefined
18
+ readonly now?: (() => number) | undefined
19
+ }
20
+
21
+ export interface PluginHealth {
22
+ readonly key: string
23
+ readonly enabled: boolean
24
+ readonly operatorDisabled: boolean
25
+ readonly disabledReason: string | null
26
+ readonly calls: number
27
+ readonly failures: number
28
+ readonly slowCalls: number
29
+ readonly totalMs: number
30
+ readonly lastError: { readonly hook: string; readonly message: string } | null
31
+ }
32
+
33
+ type StoredHandler = (value: unknown, context: unknown) => unknown
34
+
35
+ interface Entry {
36
+ readonly pluginKey: string
37
+ readonly priority: number
38
+ readonly handler: StoredHandler
39
+ }
40
+
41
+ interface Stats {
42
+ enabled: boolean
43
+ operatorDisabled: boolean
44
+ disabledReason: string | null
45
+ calls: number
46
+ failures: number
47
+ slowCalls: number
48
+ totalMs: number
49
+ lastError: { hook: string; message: string } | null
50
+ }
51
+
52
+ const DEFAULT_PRIORITY = 100
53
+
54
+ export class PluginHost {
55
+ readonly #entries = new Map<HookName, Entry[]>()
56
+ readonly #contributions = new Map<PluginRegion, { pluginKey: string; priority: number; contribution: PluginContribution }[]>()
57
+ readonly #stats = new Map<string, Stats>()
58
+ readonly #logger: HostLogger
59
+ readonly #failureThreshold: number
60
+ readonly #slowCallMs: number
61
+ readonly #now: () => number
62
+
63
+ constructor(options: PluginHostOptions) {
64
+ this.#logger = options.logger ?? { warn: () => {}, error: () => {} }
65
+ this.#failureThreshold = options.failureThreshold ?? 5
66
+ this.#slowCallMs = options.slowCallMs ?? 50
67
+ this.#now = options.now ?? (() => performance.now())
68
+
69
+ for (const plugin of options.plugins) {
70
+ this.#stats.set(plugin.key, {
71
+ enabled: true,
72
+ operatorDisabled: false,
73
+ disabledReason: null,
74
+ calls: 0,
75
+ failures: 0,
76
+ slowCalls: 0,
77
+ totalMs: 0,
78
+ lastError: null,
79
+ })
80
+
81
+ for (const [name, registration] of Object.entries(plugin.hooks ?? {})) {
82
+ const hook = name as HookName
83
+ const handler = (
84
+ typeof registration === 'function' ? registration : (registration as HookRegistration<HookName>).handler
85
+ ) as StoredHandler
86
+ const priority =
87
+ typeof registration === 'function'
88
+ ? DEFAULT_PRIORITY
89
+ : (registration.priority ?? DEFAULT_PRIORITY)
90
+
91
+ const list = this.#entries.get(hook) ?? []
92
+ list.push({ pluginKey: plugin.key, priority, handler })
93
+ this.#entries.set(hook, list)
94
+ }
95
+
96
+ for (const contribution of plugin.contributions ?? []) {
97
+ const list = this.#contributions.get(contribution.region) ?? []
98
+ list.push({
99
+ pluginKey: plugin.key,
100
+ priority: contribution.priority ?? DEFAULT_PRIORITY,
101
+ contribution,
102
+ })
103
+ this.#contributions.set(contribution.region, list)
104
+ }
105
+ }
106
+
107
+ const byPriorityThenKey = <T extends { priority: number; pluginKey: string }>(a: T, b: T): number =>
108
+ a.priority - b.priority || (a.pluginKey < b.pluginKey ? -1 : a.pluginKey > b.pluginKey ? 1 : 0)
109
+
110
+ for (const list of this.#entries.values()) list.sort(byPriorityThenKey)
111
+ for (const list of this.#contributions.values()) list.sort(byPriorityThenKey)
112
+ }
113
+
114
+ async applyFilter<K extends HookName>(
115
+ name: K,
116
+ value: HookValue<K>,
117
+ context: HookContext<K>,
118
+ ): Promise<HookValue<K>> {
119
+ const entries = this.#entries.get(name)
120
+ if (entries === undefined || entries.length === 0) return value
121
+
122
+ let current = value
123
+ for (const entry of entries) {
124
+ if (!this.#isEnabled(entry.pluginKey)) continue
125
+
126
+ const result = await this.#call(entry.pluginKey, name, () => entry.handler(current, context))
127
+
128
+ if (result.ok && result.value !== undefined) current = result.value as HookValue<K>
129
+ }
130
+ return current
131
+ }
132
+
133
+ async emit<K extends HookName>(name: K, value: HookValue<K>, context: HookContext<K>): Promise<void> {
134
+ const entries = this.#entries.get(name)
135
+ if (entries === undefined) return
136
+
137
+ for (const entry of entries) {
138
+ if (!this.#isEnabled(entry.pluginKey)) continue
139
+ await this.#call(entry.pluginKey, name, () => entry.handler(value, context))
140
+ }
141
+ }
142
+
143
+ renderRegion(
144
+ region: PluginRegion,
145
+ context: PluginRegionContext,
146
+ ): readonly { key: string; node: ReactNode }[] {
147
+ const entries = this.#contributions.get(region)
148
+ if (entries === undefined) return []
149
+
150
+ const nodes: { key: string; node: ReactNode }[] = []
151
+ for (const entry of entries) {
152
+ if (!this.#isEnabled(entry.pluginKey)) continue
153
+
154
+ const started = this.#now()
155
+ try {
156
+ const node = entry.contribution.render(context)
157
+ this.#record(entry.pluginKey, region, this.#now() - started)
158
+ if (node !== null && node !== undefined) nodes.push({ key: entry.pluginKey, node })
159
+ } catch (error) {
160
+ this.#fail(entry.pluginKey, region, error)
161
+ }
162
+ }
163
+ return nodes
164
+ }
165
+
166
+ health(): readonly PluginHealth[] {
167
+ return [...this.#stats.entries()]
168
+ .map(([key, stats]) => ({
169
+ key,
170
+ enabled: stats.enabled && !stats.operatorDisabled,
171
+ operatorDisabled: stats.operatorDisabled,
172
+ disabledReason: stats.disabledReason,
173
+ calls: stats.calls,
174
+ failures: stats.failures,
175
+ slowCalls: stats.slowCalls,
176
+ totalMs: Math.round(stats.totalMs * 100) / 100,
177
+ lastError: stats.lastError === null ? null : { ...stats.lastError },
178
+ }))
179
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
180
+ }
181
+
182
+ disable(pluginKey: string, reason: string): void {
183
+ const stats = this.#stats.get(pluginKey)
184
+ if (stats === undefined || !stats.enabled) return
185
+ stats.enabled = false
186
+ stats.disabledReason = reason
187
+ this.#logger.error('plugin disabled', { plugin: pluginKey, reason })
188
+ }
189
+
190
+ setOperatorDisabled(keys: readonly string[]): void {
191
+ const disabled = new Set(keys)
192
+ for (const [key, stats] of this.#stats) {
193
+ stats.operatorDisabled = disabled.has(key)
194
+ }
195
+ }
196
+
197
+ isEnabled(pluginKey: string): boolean {
198
+ return this.#isEnabled(pluginKey)
199
+ }
200
+
201
+ async run<T>(
202
+ pluginKey: string,
203
+ surface: string,
204
+ invoke: () => Promise<T> | T,
205
+ ): Promise<{ status: 'ok'; value: T } | { status: 'failed' } | { status: 'disabled' }> {
206
+ if (!this.#isEnabled(pluginKey)) return { status: 'disabled' }
207
+
208
+ const outcome = await this.#call(pluginKey, surface, invoke)
209
+ return outcome.ok ? { status: 'ok', value: outcome.value as T } : { status: 'failed' }
210
+ }
211
+
212
+ listeners(): Readonly<Record<string, readonly string[]>> {
213
+ const out: Record<string, string[]> = {}
214
+ for (const [hook, entries] of this.#entries) {
215
+ out[hook] = entries.map((entry) => entry.pluginKey)
216
+ }
217
+ return out
218
+ }
219
+
220
+ #isEnabled(pluginKey: string): boolean {
221
+ const stats = this.#stats.get(pluginKey)
222
+ return stats !== undefined && stats.enabled && !stats.operatorDisabled
223
+ }
224
+
225
+ async #call(
226
+ pluginKey: string,
227
+ hook: string,
228
+ invoke: () => unknown,
229
+ ): Promise<{ ok: true; value: unknown } | { ok: false }> {
230
+ const started = this.#now()
231
+ try {
232
+ const value = await invoke()
233
+ this.#record(pluginKey, hook, this.#now() - started)
234
+ return { ok: true, value }
235
+ } catch (error) {
236
+ this.#record(pluginKey, hook, this.#now() - started)
237
+ this.#fail(pluginKey, hook, error)
238
+ return { ok: false }
239
+ }
240
+ }
241
+
242
+ #record(pluginKey: string, hook: string, elapsedMs: number): void {
243
+ const stats = this.#stats.get(pluginKey)
244
+ if (stats === undefined) return
245
+
246
+ stats.calls += 1
247
+ stats.totalMs += elapsedMs
248
+ if (elapsedMs >= this.#slowCallMs) {
249
+ stats.slowCalls += 1
250
+ this.#logger.warn('slow plugin hook', {
251
+ plugin: pluginKey,
252
+ hook,
253
+ ms: Math.round(elapsedMs),
254
+ })
255
+ }
256
+ }
257
+
258
+ #fail(pluginKey: string, hook: string, error: unknown): void {
259
+ const stats = this.#stats.get(pluginKey)
260
+ if (stats === undefined) return
261
+
262
+ stats.failures += 1
263
+ const message = error instanceof Error ? error.message : String(error)
264
+ stats.lastError = { hook, message }
265
+
266
+ this.#logger.error('plugin hook failed', { plugin: pluginKey, hook, message })
267
+
268
+ if (stats.failures >= this.#failureThreshold) {
269
+ this.disable(
270
+ pluginKey,
271
+ `${stats.failures} failures in this process, most recently in "${hook}"`,
272
+ )
273
+ }
274
+ }
275
+ }
276
+
277
+ export function emptyHost(): PluginHost {
278
+ return new PluginHost({ plugins: [] })
279
+ }
280
+
281
+ export function isFilter(name: HookName): boolean {
282
+ return HOOKS[name].kind === 'filter'
283
+ }
package/src/index.ts ADDED
@@ -0,0 +1,118 @@
1
+ export {
2
+ HOOKS,
3
+ HOOK_NAMES,
4
+ hookKind,
5
+ isHookName,
6
+ type HookKind,
7
+ type HookName,
8
+ type HookSpec,
9
+ } from './hooks'
10
+
11
+ export type {
12
+ DraftPayload,
13
+ ForumRef,
14
+ HookContext,
15
+ HookSignatures,
16
+ HookValue,
17
+ ModerationRef,
18
+ PostRef,
19
+ RequestRef,
20
+ ThreadRef,
21
+ UserRef,
22
+ ValidationMessages,
23
+ ViewerRef,
24
+ } from './payloads'
25
+
26
+ export {
27
+ DEFAULT_ROUTE_BODY_BYTES,
28
+ MAX_ROUTE_BODY_BYTES,
29
+ definePlugin,
30
+ pluginAdminPath,
31
+ pluginAdminRoutePath,
32
+ pluginNotificationKindId,
33
+ pluginPagePath,
34
+ pluginRoutePath,
35
+ pluginSettingKey,
36
+ pluginTablePrefix,
37
+ pluginTaskId,
38
+ type EventHandler,
39
+ type FilterHandler,
40
+ type HookHandler,
41
+ type HookRegistration,
42
+ type PluginAdminPage,
43
+ type PluginAdminPageContext,
44
+ type PluginBoardPage,
45
+ type PluginContribution,
46
+ type PluginDefinition,
47
+ type PluginHooks,
48
+ type PluginMigration,
49
+ type PluginNotificationKind,
50
+ type PluginPageAccess,
51
+ type PluginPageContext,
52
+ type PluginRequest,
53
+ type PluginResponse,
54
+ type PluginRoute,
55
+ type PluginRouteAccess,
56
+ type PluginRouteRateLimit,
57
+ type PluginRuntimeContext,
58
+ type PluginSetting,
59
+ type PluginSettingType,
60
+ type PluginTask,
61
+ type PluginViewer,
62
+ } from './plugin'
63
+
64
+ export {
65
+ operatorDisabledPlugins,
66
+ parsePluginSetting,
67
+ pluginEnabledKey,
68
+ pluginSettingType,
69
+ resolvePluginSettingDetails,
70
+ resolvePluginSettings,
71
+ serialisePluginSetting,
72
+ type PluginEnvReader,
73
+ type PluginSettingSource,
74
+ type PluginSettingValue,
75
+ type ResolvedPluginSetting,
76
+ } from './settings'
77
+
78
+ export {
79
+ PLUGIN_REGIONS,
80
+ REGION_NAMES,
81
+ isPluginRegion,
82
+ type PluginRegion,
83
+ type PluginRegionContext,
84
+ type RegionSpec,
85
+ } from './regions'
86
+
87
+ export {
88
+ PluginHost,
89
+ emptyHost,
90
+ isFilter,
91
+ type HostLogger,
92
+ type PluginHealth,
93
+ type PluginHostOptions,
94
+ } from './host'
95
+
96
+ export {
97
+ pluginNotificationKindSpecs,
98
+ pluginNotify,
99
+ unavailablePluginData,
100
+ unavailablePluginGrants,
101
+ unavailablePluginNotify,
102
+ unavailablePluginUsers,
103
+ type PluginData,
104
+ type PluginGrantRow,
105
+ type PluginGrants,
106
+ type PluginNotificationKindSpec,
107
+ type PluginNotify,
108
+ type PluginNotifyBackend,
109
+ type PluginNotifyKindInput,
110
+ type PluginUserRef,
111
+ type PluginUsers,
112
+ } from './runtime'
113
+
114
+ export {
115
+ createRouteRateLimiter,
116
+ type RateLimitVerdict,
117
+ type RouteRateLimiter,
118
+ } from './rate-limit'