@meith/plugin-kit 0.21.2 → 0.23.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/package.json +3 -3
- package/src/host.ts +42 -17
- package/src/index.ts +4 -0
- package/src/navigation.ts +0 -1
- package/src/plugin.ts +6 -36
- package/src/regions.ts +9 -0
- package/src/runtime.ts +30 -0
- package/src/settings.ts +0 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/plugin-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "The SDK for writing a Meith plugin: typed manifests, hooks, routes, pages and migrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@meith/core": "^0.
|
|
24
|
-
"@meith/theme-kit": "^0.
|
|
23
|
+
"@meith/core": "^0.23.0",
|
|
24
|
+
"@meith/theme-kit": "^0.23.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"react": "^19.2.0"
|
package/src/host.ts
CHANGED
|
@@ -2,7 +2,13 @@ import type { ReactNode } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import { HOOKS, type HookName } from './hooks'
|
|
4
4
|
import type { HookContext, HookValue } from './payloads'
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
HookRegistration,
|
|
7
|
+
HookRuntime,
|
|
8
|
+
PluginContribution,
|
|
9
|
+
PluginDefinition,
|
|
10
|
+
PluginRuntimeContext,
|
|
11
|
+
} from './plugin'
|
|
6
12
|
import type { PluginRegion, PluginRegionContext } from './regions'
|
|
7
13
|
|
|
8
14
|
export interface HostLogger {
|
|
@@ -17,10 +23,6 @@ export interface PluginFailure {
|
|
|
17
23
|
readonly threshold: number
|
|
18
24
|
}
|
|
19
25
|
|
|
20
|
-
/**
|
|
21
|
-
* Where a failure goes once the host has counted it. The host stays
|
|
22
|
-
* synchronous and fire-and-forgets; whoever supplies this owns the write.
|
|
23
|
-
*/
|
|
24
26
|
export interface PluginHealthSink {
|
|
25
27
|
readonly failed: (failure: PluginFailure) => void
|
|
26
28
|
}
|
|
@@ -30,6 +32,8 @@ export interface DurablyDisabledPlugin {
|
|
|
30
32
|
readonly reason: string
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
export type PluginRuntimeProvider = (pluginKey: string) => Promise<PluginRuntimeContext>
|
|
36
|
+
|
|
33
37
|
export interface PluginHostOptions {
|
|
34
38
|
readonly plugins: readonly PluginDefinition[]
|
|
35
39
|
readonly logger?: HostLogger | undefined
|
|
@@ -37,6 +41,7 @@ export interface PluginHostOptions {
|
|
|
37
41
|
readonly slowCallMs?: number | undefined
|
|
38
42
|
readonly now?: (() => number) | undefined
|
|
39
43
|
readonly health?: PluginHealthSink | undefined
|
|
44
|
+
readonly runtime?: PluginRuntimeProvider | undefined
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export interface PluginHealth {
|
|
@@ -52,7 +57,7 @@ export interface PluginHealth {
|
|
|
52
57
|
readonly lastError: { readonly hook: string; readonly message: string } | null
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
type StoredHandler = (value: unknown, context: unknown) => unknown
|
|
60
|
+
type StoredHandler = (value: unknown, context: unknown, runtime: HookRuntime) => unknown
|
|
56
61
|
|
|
57
62
|
interface Entry {
|
|
58
63
|
readonly pluginKey: string
|
|
@@ -86,6 +91,7 @@ export class PluginHost {
|
|
|
86
91
|
readonly #slowCallMs: number
|
|
87
92
|
readonly #now: () => number
|
|
88
93
|
readonly #health: PluginHealthSink
|
|
94
|
+
readonly #runtime: PluginRuntimeProvider
|
|
89
95
|
|
|
90
96
|
constructor(options: PluginHostOptions) {
|
|
91
97
|
this.#logger = options.logger ?? { warn: () => {}, error: () => {} }
|
|
@@ -93,6 +99,15 @@ export class PluginHost {
|
|
|
93
99
|
this.#slowCallMs = options.slowCallMs ?? 50
|
|
94
100
|
this.#now = options.now ?? (() => performance.now())
|
|
95
101
|
this.#health = options.health ?? { failed: () => {} }
|
|
102
|
+
this.#runtime =
|
|
103
|
+
options.runtime ??
|
|
104
|
+
((pluginKey) =>
|
|
105
|
+
Promise.reject(
|
|
106
|
+
new Error(
|
|
107
|
+
`plugin "${pluginKey}": this host was built without a runtime provider, so a hook ` +
|
|
108
|
+
'handler cannot reach settings, data, grants, users or notifications here.',
|
|
109
|
+
),
|
|
110
|
+
))
|
|
96
111
|
|
|
97
112
|
for (const plugin of options.plugins) {
|
|
98
113
|
this.#stats.set(plugin.key, {
|
|
@@ -158,7 +173,9 @@ export class PluginHost {
|
|
|
158
173
|
for (const entry of entries) {
|
|
159
174
|
if (!this.#isEnabled(entry.pluginKey)) continue
|
|
160
175
|
|
|
161
|
-
const result = await this.#call(entry.pluginKey, name, () =>
|
|
176
|
+
const result = await this.#call(entry.pluginKey, name, () =>
|
|
177
|
+
entry.handler(current, context, this.#runtimeFor(entry.pluginKey)),
|
|
178
|
+
)
|
|
162
179
|
|
|
163
180
|
if (result.ok && result.value !== undefined) current = result.value as HookValue<K>
|
|
164
181
|
}
|
|
@@ -175,14 +192,24 @@ export class PluginHost {
|
|
|
175
192
|
|
|
176
193
|
for (const entry of entries) {
|
|
177
194
|
if (!this.#isEnabled(entry.pluginKey)) continue
|
|
178
|
-
await this.#call(entry.pluginKey, name, () =>
|
|
195
|
+
await this.#call(entry.pluginKey, name, () =>
|
|
196
|
+
entry.handler(value, context, this.#runtimeFor(entry.pluginKey)),
|
|
197
|
+
)
|
|
179
198
|
}
|
|
180
199
|
}
|
|
181
200
|
|
|
182
|
-
|
|
201
|
+
#runtimeFor(pluginKey: string): HookRuntime {
|
|
202
|
+
let pending: Promise<PluginRuntimeContext> | null = null
|
|
203
|
+
return () => {
|
|
204
|
+
pending ??= this.#runtime(pluginKey)
|
|
205
|
+
return pending
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async renderRegion(
|
|
183
210
|
region: PluginRegion,
|
|
184
|
-
context: PluginRegionContext,
|
|
185
|
-
): readonly { key: string; node: ReactNode }[] {
|
|
211
|
+
context: Omit<PluginRegionContext, 'runtime'>,
|
|
212
|
+
): Promise<readonly { key: string; node: ReactNode }[]> {
|
|
186
213
|
const entries = this.#contributions.get(region)
|
|
187
214
|
if (entries === undefined) return []
|
|
188
215
|
|
|
@@ -192,7 +219,10 @@ export class PluginHost {
|
|
|
192
219
|
|
|
193
220
|
const started = this.#now()
|
|
194
221
|
try {
|
|
195
|
-
const node = entry.contribution.render(
|
|
222
|
+
const node = await entry.contribution.render({
|
|
223
|
+
...context,
|
|
224
|
+
runtime: this.#runtimeFor(entry.pluginKey),
|
|
225
|
+
})
|
|
196
226
|
this.#record(entry.pluginKey, region, this.#now() - started)
|
|
197
227
|
if (node !== null && node !== undefined) nodes.push({ key: entry.pluginKey, node })
|
|
198
228
|
} catch (error) {
|
|
@@ -234,11 +264,6 @@ export class PluginHost {
|
|
|
234
264
|
}
|
|
235
265
|
}
|
|
236
266
|
|
|
237
|
-
/**
|
|
238
|
-
* Reconcile against the durable record. The stored rows are the answer,
|
|
239
|
-
* not a hint: a plugin an operator has cleared comes back without a
|
|
240
|
-
* restart, and one another instance switched off is off here too.
|
|
241
|
-
*/
|
|
242
267
|
setDurablyDisabled(rows: readonly DurablyDisabledPlugin[]): void {
|
|
243
268
|
const disabled = new Map(rows.map((row) => [row.key, row.reason]))
|
|
244
269
|
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
type PluginHealthSink,
|
|
18
18
|
PluginHost,
|
|
19
19
|
type PluginHostOptions,
|
|
20
|
+
type PluginRuntimeProvider,
|
|
20
21
|
} from './host'
|
|
21
22
|
export {
|
|
22
23
|
type PluginNavigationPlacement,
|
|
@@ -44,6 +45,7 @@ export {
|
|
|
44
45
|
type FilterHandler,
|
|
45
46
|
type HookHandler,
|
|
46
47
|
type HookRegistration,
|
|
48
|
+
type HookRuntime,
|
|
47
49
|
MAX_ROUTE_BODY_BYTES,
|
|
48
50
|
type PluginAdminPage,
|
|
49
51
|
type PluginAdminPageContext,
|
|
@@ -107,9 +109,11 @@ export {
|
|
|
107
109
|
type PluginUsers,
|
|
108
110
|
pluginNotificationKindSpecs,
|
|
109
111
|
pluginNotify,
|
|
112
|
+
unavailableHookRuntime,
|
|
110
113
|
unavailablePluginData,
|
|
111
114
|
unavailablePluginGrants,
|
|
112
115
|
unavailablePluginNotify,
|
|
116
|
+
unavailablePluginRuntime,
|
|
113
117
|
unavailablePluginUsers,
|
|
114
118
|
} from './runtime'
|
|
115
119
|
export {
|
package/src/navigation.ts
CHANGED
|
@@ -9,7 +9,6 @@ export interface PluginNavigationPlacement {
|
|
|
9
9
|
readonly key: string
|
|
10
10
|
readonly href: string
|
|
11
11
|
readonly audience: PluginNavigationAudience
|
|
12
|
-
/** The namespaced key of the sibling item this one sits under by default. */
|
|
13
12
|
readonly parentKey: string | null
|
|
14
13
|
readonly label: string
|
|
15
14
|
readonly labelKey: string | null
|
package/src/plugin.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { HookRuntime, PluginRuntimeContext } from './runtime'
|
|
2
|
+
|
|
1
3
|
import type { ReactNode } from 'react'
|
|
2
4
|
|
|
3
5
|
import type { Translator } from '@meith/theme-kit'
|
|
@@ -5,16 +7,18 @@ import type { Translator } from '@meith/theme-kit'
|
|
|
5
7
|
import { type HOOKS, type HookName, isHookName } from './hooks'
|
|
6
8
|
import type { HookContext, HookValue } from './payloads'
|
|
7
9
|
import { isPluginRegion, type PluginRegion, type PluginRegionContext } from './regions'
|
|
8
|
-
import type {
|
|
10
|
+
import type { HookRuntime, PluginRuntimeContext } from './runtime'
|
|
9
11
|
|
|
10
12
|
export type FilterHandler<K extends HookName> = (
|
|
11
13
|
value: HookValue<K>,
|
|
12
14
|
context: HookContext<K>,
|
|
15
|
+
runtime: HookRuntime,
|
|
13
16
|
) => HookValue<K> | Promise<HookValue<K>>
|
|
14
17
|
|
|
15
18
|
export type EventHandler<K extends HookName> = (
|
|
16
19
|
value: HookValue<K>,
|
|
17
20
|
context: HookContext<K>,
|
|
21
|
+
runtime: HookRuntime,
|
|
18
22
|
) => void | Promise<void>
|
|
19
23
|
|
|
20
24
|
export type HookHandler<K extends HookName> = (typeof HOOKS)[K]['kind'] extends 'filter'
|
|
@@ -74,7 +78,7 @@ export interface PluginAdminPage {
|
|
|
74
78
|
export interface PluginContribution {
|
|
75
79
|
readonly region: PluginRegion
|
|
76
80
|
readonly priority?: number | undefined
|
|
77
|
-
readonly render: (context: PluginRegionContext) => ReactNode
|
|
81
|
+
readonly render: (context: PluginRegionContext) => ReactNode | Promise<ReactNode>
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
export interface PluginViewer {
|
|
@@ -153,44 +157,16 @@ type TranslationArgs = Parameters<Translator['t']>[1]
|
|
|
153
157
|
|
|
154
158
|
export type PluginNavigationAudience = 'all' | 'guests' | 'members' | 'staff'
|
|
155
159
|
|
|
156
|
-
/**
|
|
157
|
-
* A board navigation entry a plugin asks for.
|
|
158
|
-
*
|
|
159
|
-
* It is a **request, not a placement**: the host writes it into the board's own
|
|
160
|
-
* navigation table, where an operator renames, reorders, nests, scopes or hides
|
|
161
|
-
* it like any other item. A plugin that appended to the header model instead
|
|
162
|
-
* would put a link where no operator could reach it.
|
|
163
|
-
*/
|
|
164
160
|
export interface PluginNavigationItem {
|
|
165
161
|
readonly key: string
|
|
166
162
|
readonly label: string
|
|
167
163
|
readonly labelKey?: string | undefined
|
|
168
164
|
readonly labelArgs?: TranslationArgs | undefined
|
|
169
|
-
/** A page path of this plugin's own — '' is its index page. */
|
|
170
165
|
readonly path: string
|
|
171
166
|
readonly audience?: PluginNavigationAudience | undefined
|
|
172
|
-
/**
|
|
173
|
-
* The `key` of another of this plugin's navigation items to sit under by
|
|
174
|
-
* default. The board's navigation is one level deep, so the item named here
|
|
175
|
-
* must itself be top-level. Like `audience`, it only seeds the row — the
|
|
176
|
-
* operator re-nests it like any other item.
|
|
177
|
-
*/
|
|
178
167
|
readonly under?: string | undefined
|
|
179
168
|
}
|
|
180
169
|
|
|
181
|
-
export interface PluginRuntimeContext {
|
|
182
|
-
readonly settings: Readonly<Record<string, string | number | boolean>>
|
|
183
|
-
readonly logger: {
|
|
184
|
-
readonly info: (message: string, detail?: Record<string, unknown>) => void
|
|
185
|
-
readonly warn: (message: string, detail?: Record<string, unknown>) => void
|
|
186
|
-
readonly error: (message: string, detail?: Record<string, unknown>) => void
|
|
187
|
-
}
|
|
188
|
-
readonly grants: PluginGrants
|
|
189
|
-
readonly data: PluginData
|
|
190
|
-
readonly users: PluginUsers
|
|
191
|
-
readonly notify: PluginNotify
|
|
192
|
-
}
|
|
193
|
-
|
|
194
170
|
export interface PluginNotificationKind {
|
|
195
171
|
readonly key: string
|
|
196
172
|
readonly title: string
|
|
@@ -231,12 +207,6 @@ export interface PluginDefinition {
|
|
|
231
207
|
readonly onUninstall?: ((context: PluginRuntimeContext) => Promise<void> | void) | undefined
|
|
232
208
|
}
|
|
233
209
|
|
|
234
|
-
/**
|
|
235
|
-
* The rule a plugin key (and, by marketplace-gen.mjs's own mirrored copy, a
|
|
236
|
-
* marketplace listing key) has to satisfy. Exported so
|
|
237
|
-
* scripts/marketplace-gen.test.ts can pin its own copy directly against
|
|
238
|
-
* this one rather than trusting the two stay in sync by comment alone.
|
|
239
|
-
*/
|
|
240
210
|
export const KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
|
|
241
211
|
const SETTING_KEY_PATTERN = /^[a-z][a-z0-9_]{1,39}$/
|
|
242
212
|
const MIGRATION_ID_PATTERN = /^\d{4}_[a-z0-9_]{1,60}$/
|
package/src/regions.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { HookRuntime } from './runtime'
|
|
2
|
+
|
|
1
3
|
export interface RegionSpec {
|
|
2
4
|
readonly purpose: string
|
|
3
5
|
readonly context: string
|
|
@@ -22,6 +24,12 @@ export const PLUGIN_REGIONS = {
|
|
|
22
24
|
purpose: 'Below a post body, above its actions.',
|
|
23
25
|
context: 'The viewer, the post id and the author id.',
|
|
24
26
|
},
|
|
27
|
+
'thread.header': {
|
|
28
|
+
purpose:
|
|
29
|
+
'Above the first post of a thread, below its title. Runs once per thread page, ' +
|
|
30
|
+
'so unlike postbit.* it can afford to read from the plugin’s own tables.',
|
|
31
|
+
context: 'The viewer, the thread id and the thread author’s id.',
|
|
32
|
+
},
|
|
25
33
|
'profile.panel': {
|
|
26
34
|
purpose: 'A panel on a member’s profile, below the standard fields.',
|
|
27
35
|
context: 'The viewer and the profile’s member id.',
|
|
@@ -45,4 +53,5 @@ export interface PluginRegionContext {
|
|
|
45
53
|
readonly viewer: { readonly userId: number | null; readonly isGuest: boolean }
|
|
46
54
|
readonly subjectId: number | null
|
|
47
55
|
readonly authorId: number | null
|
|
56
|
+
readonly runtime: HookRuntime
|
|
48
57
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -246,3 +246,33 @@ export function pluginNotify(
|
|
|
246
246
|
},
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
+
|
|
250
|
+
export interface PluginRuntimeContext {
|
|
251
|
+
readonly settings: Readonly<Record<string, string | number | boolean>>
|
|
252
|
+
readonly logger: {
|
|
253
|
+
readonly info: (message: string, detail?: Record<string, unknown>) => void
|
|
254
|
+
readonly warn: (message: string, detail?: Record<string, unknown>) => void
|
|
255
|
+
readonly error: (message: string, detail?: Record<string, unknown>) => void
|
|
256
|
+
}
|
|
257
|
+
readonly grants: PluginGrants
|
|
258
|
+
readonly data: PluginData
|
|
259
|
+
readonly users: PluginUsers
|
|
260
|
+
readonly notify: PluginNotify
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export type HookRuntime = () => Promise<PluginRuntimeContext>
|
|
264
|
+
|
|
265
|
+
export function unavailablePluginRuntime(reason: string): PluginRuntimeContext {
|
|
266
|
+
return {
|
|
267
|
+
settings: {},
|
|
268
|
+
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
269
|
+
grants: unavailablePluginGrants(reason),
|
|
270
|
+
data: unavailablePluginData(reason),
|
|
271
|
+
users: unavailablePluginUsers(reason),
|
|
272
|
+
notify: unavailablePluginNotify(reason),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function unavailableHookRuntime(reason: string): HookRuntime {
|
|
277
|
+
return () => Promise.resolve(unavailablePluginRuntime(reason))
|
|
278
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -47,11 +47,6 @@ export function parsePluginSetting(setting: PluginSetting, raw: string): PluginS
|
|
|
47
47
|
return raw
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
* Matches a candidate select value against a setting's declared options —
|
|
52
|
-
* trimmed and case-insensitively — and returns the *option's own* casing.
|
|
53
|
-
* See "Settings" in docs/plugin-api.md.
|
|
54
|
-
*/
|
|
55
50
|
function matchSelectOption(
|
|
56
51
|
options: readonly { readonly value: string }[],
|
|
57
52
|
candidate: PluginSettingValue | null,
|