@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/LICENSE.md +165 -0
- package/package.json +31 -0
- package/src/hooks.ts +497 -0
- package/src/host.ts +283 -0
- package/src/index.ts +118 -0
- package/src/payloads.ts +414 -0
- package/src/plugin.ts +559 -0
- package/src/rate-limit.ts +42 -0
- package/src/regions.ts +48 -0
- package/src/runtime.ts +187 -0
- package/src/settings.ts +131 -0
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'
|