@meith/plugin-kit 0.21.1 → 0.22.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-kit",
3
- "version": "0.21.1",
3
+ "version": "0.22.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.21.1",
24
- "@meith/theme-kit": "^0.21.1"
23
+ "@meith/core": "^0.22.0",
24
+ "@meith/theme-kit": "^0.22.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 { HookRegistration, PluginContribution, PluginDefinition } from './plugin'
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, () => entry.handler(current, context))
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,7 +192,17 @@ 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, () => entry.handler(value, context))
195
+ await this.#call(entry.pluginKey, name, () =>
196
+ entry.handler(value, context, this.#runtimeFor(entry.pluginKey)),
197
+ )
198
+ }
199
+ }
200
+
201
+ #runtimeFor(pluginKey: string): HookRuntime {
202
+ let pending: Promise<PluginRuntimeContext> | null = null
203
+ return () => {
204
+ pending ??= this.#runtime(pluginKey)
205
+ return pending
179
206
  }
180
207
  }
181
208
 
@@ -234,11 +261,6 @@ export class PluginHost {
234
261
  }
235
262
  }
236
263
 
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
264
  setDurablyDisabled(rows: readonly DurablyDisabledPlugin[]): void {
243
265
  const disabled = new Map(rows.map((row) => [row.key, row.reason]))
244
266
 
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,
@@ -76,6 +78,8 @@ export {
76
78
  pluginSettingKey,
77
79
  pluginTablePrefix,
78
80
  pluginTaskId,
81
+ unavailableHookRuntime,
82
+ unavailablePluginRuntime,
79
83
  } from './plugin'
80
84
  export {
81
85
  createRouteRateLimiter,
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
@@ -5,16 +5,29 @@ import type { Translator } from '@meith/theme-kit'
5
5
  import { type HOOKS, type HookName, isHookName } from './hooks'
6
6
  import type { HookContext, HookValue } from './payloads'
7
7
  import { isPluginRegion, type PluginRegion, type PluginRegionContext } from './regions'
8
- import type { PluginData, PluginGrants, PluginNotify, PluginUsers } from './runtime'
8
+ import {
9
+ type PluginData,
10
+ type PluginGrants,
11
+ type PluginNotify,
12
+ type PluginUsers,
13
+ unavailablePluginData,
14
+ unavailablePluginGrants,
15
+ unavailablePluginNotify,
16
+ unavailablePluginUsers,
17
+ } from './runtime'
18
+
19
+ export type HookRuntime = () => Promise<PluginRuntimeContext>
9
20
 
10
21
  export type FilterHandler<K extends HookName> = (
11
22
  value: HookValue<K>,
12
23
  context: HookContext<K>,
24
+ runtime: HookRuntime,
13
25
  ) => HookValue<K> | Promise<HookValue<K>>
14
26
 
15
27
  export type EventHandler<K extends HookName> = (
16
28
  value: HookValue<K>,
17
29
  context: HookContext<K>,
30
+ runtime: HookRuntime,
18
31
  ) => void | Promise<void>
19
32
 
20
33
  export type HookHandler<K extends HookName> = (typeof HOOKS)[K]['kind'] extends 'filter'
@@ -153,28 +166,13 @@ type TranslationArgs = Parameters<Translator['t']>[1]
153
166
 
154
167
  export type PluginNavigationAudience = 'all' | 'guests' | 'members' | 'staff'
155
168
 
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
169
  export interface PluginNavigationItem {
165
170
  readonly key: string
166
171
  readonly label: string
167
172
  readonly labelKey?: string | undefined
168
173
  readonly labelArgs?: TranslationArgs | undefined
169
- /** A page path of this plugin's own — '' is its index page. */
170
174
  readonly path: string
171
175
  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
176
  readonly under?: string | undefined
179
177
  }
180
178
 
@@ -191,6 +189,21 @@ export interface PluginRuntimeContext {
191
189
  readonly notify: PluginNotify
192
190
  }
193
191
 
192
+ export function unavailablePluginRuntime(reason: string): PluginRuntimeContext {
193
+ return {
194
+ settings: {},
195
+ logger: { info: () => {}, warn: () => {}, error: () => {} },
196
+ grants: unavailablePluginGrants(reason),
197
+ data: unavailablePluginData(reason),
198
+ users: unavailablePluginUsers(reason),
199
+ notify: unavailablePluginNotify(reason),
200
+ }
201
+ }
202
+
203
+ export function unavailableHookRuntime(reason: string): HookRuntime {
204
+ return () => Promise.resolve(unavailablePluginRuntime(reason))
205
+ }
206
+
194
207
  export interface PluginNotificationKind {
195
208
  readonly key: string
196
209
  readonly title: string
@@ -231,12 +244,6 @@ export interface PluginDefinition {
231
244
  readonly onUninstall?: ((context: PluginRuntimeContext) => Promise<void> | void) | undefined
232
245
  }
233
246
 
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
247
  export const KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
241
248
  const SETTING_KEY_PATTERN = /^[a-z][a-z0-9_]{1,39}$/
242
249
  const MIGRATION_ID_PATTERN = /^\d{4}_[a-z0-9_]{1,60}$/
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,