Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/community/src/server/plugin-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PluginHost,
} from '@meith/plugin-kit'

import { runtimeContextFor } from './plugin-runtime'
import { getSettingOverrides } from './settings'

const HEALTH_TTL_SECONDS = 30
Expand Down Expand Up @@ -47,9 +48,23 @@ export function activeDefinitions(): readonly PluginDefinition[] {
.map((entry) => entry.plugin as PluginDefinition)
}

async function hookRuntimeContext(pluginKey: string) {
const definition = activeDefinitions().find((candidate) => candidate.key === pluginKey)
if (definition === undefined) {
throw new Error(`plugin "${pluginKey}" is not installed on this board.`)
}
return runtimeContextFor(
pluginKey,
definition,
await getSettingOverrides(),
logger({ component: 'plugin-hook', plugin: pluginKey }),
)
}

export const pluginHost = new PluginHost({
plugins: activeDefinitions(),
failureThreshold: FAILURE_THRESHOLD,
runtime: hookRuntimeContext,
logger: {
warn: (message, detail) => logger().warn(detail, message),
error: (message, detail) => logger().error(detail, message),
Expand Down
71 changes: 3 additions & 68 deletions apps/community/src/server/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,78 +3,13 @@ import 'server-only'
import forumConfig from '@board/config'
import type { ReactNode } from 'react'

import { env, logger, readPluginEnv } from '@meith/core'
import { getDb, pluginData, pluginGrants, pluginUsers } from '@meith/db'
import {
type PluginData,
type PluginDefinition,
type PluginGrants,
type PluginNotify,
type PluginRuntimeContext,
type PluginUsers,
pluginNotify,
resolvePluginSettings,
unavailablePluginData,
unavailablePluginGrants,
unavailablePluginNotify,
unavailablePluginUsers,
} from '@meith/plugin-kit'
import { logger } from '@meith/core'
import type { PluginDefinition } from '@meith/plugin-kit'

import { getTranslator } from './i18n'
import { notificationService } from './notifications'
import { runtimeContextFor } from './plugin-runtime'
import { getSettingOverrides } from './settings'

const REQUEST_STATEMENT_TIMEOUT_MS = 3_000

export function grantsFor(pluginKey: string): PluginGrants {
return env.DATA_SOURCE === 'postgres'
? pluginGrants(getDb(), pluginKey)
: unavailablePluginGrants('this board is running on in-memory sample data')
}

export function dataFor(pluginKey: string): PluginData {
return env.DATA_SOURCE === 'postgres'
? pluginData(getDb(), pluginKey, { statementTimeoutMs: REQUEST_STATEMENT_TIMEOUT_MS })
: unavailablePluginData('this board is running on in-memory sample data')
}

export function usersFor(): PluginUsers {
return env.DATA_SOURCE === 'postgres'
? pluginUsers(getDb())
: unavailablePluginUsers('this board is running on in-memory sample data')
}

export function notifyFor(pluginKey: string): PluginNotify {
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
const definition = entry?.plugin as PluginDefinition | undefined
const service = notificationService()

if (definition === undefined || service === null) {
return unavailablePluginNotify('this board is running on in-memory sample data')
}
return pluginNotify(pluginKey, definition.notifications ?? [], service)
}

export function runtimeContextFor(
pluginKey: string,
definition: PluginDefinition,
overrides: ReadonlyMap<string, string>,
log: ReturnType<typeof logger>,
): PluginRuntimeContext {
return {
settings: resolvePluginSettings(definition, overrides, readPluginEnv),
logger: {
info: (message, detail) => log.info(detail ?? {}, message),
warn: (message, detail) => log.warn(detail ?? {}, message),
error: (message, detail) => log.error(detail ?? {}, message),
},
grants: grantsFor(pluginKey),
data: dataFor(pluginKey),
users: usersFor(),
notify: notifyFor(pluginKey),
}
}

export interface RenderedPluginPage {
readonly title: string
readonly node: ReactNode | null
Expand Down
2 changes: 1 addition & 1 deletion apps/community/src/server/plugin-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { recordAdminAction, requireAdmin } from './admin'
import { boardUrl } from './board-url'
import { getActor } from './context'
import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
import { runtimeContextFor } from './plugin-pages'
import { runtimeContextFor } from './plugin-runtime'
import { viewerRef } from './plugin-view'
import { isSafeLocalPath } from './safe-path'
import { isSameOrigin } from './same-origin'
Expand Down
78 changes: 78 additions & 0 deletions apps/community/src/server/plugin-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import 'server-only'

import forumConfig from '@board/config'

import { env, type logger, readPluginEnv } from '@meith/core'
import { getDb, pluginData, pluginGrants, pluginUsers } from '@meith/db'
import {
type PluginData,
type PluginDefinition,
type PluginGrants,
type PluginNotify,
type PluginRuntimeContext,
type PluginUsers,
pluginNotify,
resolvePluginSettings,
unavailablePluginData,
unavailablePluginGrants,
unavailablePluginNotify,
unavailablePluginUsers,
} from '@meith/plugin-kit'

const REQUEST_STATEMENT_TIMEOUT_MS = 3_000

export function grantsFor(pluginKey: string): PluginGrants {
return env.DATA_SOURCE === 'postgres'
? pluginGrants(getDb(), pluginKey)
: unavailablePluginGrants('this board is running on in-memory sample data')
}

export function dataFor(pluginKey: string): PluginData {
return env.DATA_SOURCE === 'postgres'
? pluginData(getDb(), pluginKey, { statementTimeoutMs: REQUEST_STATEMENT_TIMEOUT_MS })
: unavailablePluginData('this board is running on in-memory sample data')
}

export function usersFor(): PluginUsers {
return env.DATA_SOURCE === 'postgres'
? pluginUsers(getDb())
: unavailablePluginUsers('this board is running on in-memory sample data')
}

export function notifyFor(pluginKey: string): PluginNotify {
return {
async send(input) {
const { notificationService } = await import('./notifications')
const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
const definition = entry?.plugin as PluginDefinition | undefined
const service = notificationService()

const notify =
definition === undefined || service === null
? unavailablePluginNotify('this board is running on in-memory sample data')
: pluginNotify(pluginKey, definition.notifications ?? [], service)

return notify.send(input)
},
}
}

export function runtimeContextFor(
pluginKey: string,
definition: PluginDefinition,
overrides: ReadonlyMap<string, string>,
log: ReturnType<typeof logger>,
): PluginRuntimeContext {
return {
settings: resolvePluginSettings(definition, overrides, readPluginEnv),
logger: {
info: (message, detail) => log.info(detail ?? {}, message),
warn: (message, detail) => log.warn(detail ?? {}, message),
error: (message, detail) => log.error(detail ?? {}, message),
},
grants: grantsFor(pluginKey),
data: dataFor(pluginKey),
users: usersFor(),
notify: notifyFor(pluginKey),
}
}
42 changes: 42 additions & 0 deletions docs/customization/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,48 @@ These are not discouraged; there is no API for them.
> should be an event. An event handler cannot corrupt the thing it is
> watching, even when it is wrong.

Both kinds can reach this plugin's runtime, so an event handler is where a
plugin reacts to the board durably: recording a row, queueing a delivery,
raising a notification. See [reaching the runtime from a
handler](#reaching-the-runtime-from-a-handler).

### Reaching the runtime from a handler

A handler's first two arguments are the value and the hook's own context.
Its **third is a function that resolves this plugin's runtime** — the same
`settings`, `logger`, `data`, `grants`, `users` and `notify` a task or a
route is handed:

```ts
'post.created': async (post, context, runtime) => {
const { data } = await runtime()
await data.query(
'insert into plugin_example_outbox (post_id, queued_at) values ($1, now())',
[post.postId],
)
},
```

It is a function, not a value, for two reasons. Hooks are the hot path —
`view.*` filters run on every page and `postbit.badges` once per post — and
a handler that never calls it costs nothing, so the overwhelmingly common
pure-view filter pays for none of this. And acquiring the runtime can fail:
on a fixture-mode board there is no database, and `await runtime()` rejects
with a message that says so rather than handing back something that
pretends. Within a single handler call the runtime is resolved once and
reused, however many times it is asked for.

The reach is the same one everything else in the plugin gets, and no
larger: `data` still refuses anything outside `plugin_<key>_*`, `grants`
still refuses a group the operator has not opened, and a throw is still
contained and counted as [failure isolation](#failure-isolation) describes.
Testing a handler that uses it needs no board — `unavailableHookRuntime()`
stands in, and every capability on it refuses with the reason you pass:

```ts
filter(footer, viewer, unavailableHookRuntime('this test drives the filter directly'))
```

### Ordering

Handlers run in **(priority, plugin key)** order. Lower priority runs first;
Expand Down
8 changes: 6 additions & 2 deletions examples/hello-plugin/src/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import type { FilterHandler } from '@meith/plugin-kit'
import { type FilterHandler, unavailableHookRuntime } from '@meith/plugin-kit'

import { helloPlugin } from './plugin'

Expand All @@ -18,7 +18,11 @@ describe('the hello plugin', () => {
timezoneLabel: 'Europe/Dublin',
}

const filtered = filter(footer, { userId: null, isGuest: true, requestId: null })
const filtered = filter(
footer,
{ userId: null, isGuest: true, requestId: null },
unavailableHookRuntime('this test drives the filter directly'),
)

expect(filtered).toMatchObject({
boardTitle: 'A board',
Expand Down
2 changes: 1 addition & 1 deletion packages/create-meith/src/extension-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const PLUGIN_TEMPLATES: readonly ExtensionTemplate[] = [
{
path: 'src/plugin.test.ts',
contents:
"import { describe, expect, it } from 'vitest'\n\nimport type { FilterHandler } from '@meith/plugin-kit'\n\nimport { __MEITH_EXTENSION_CAMEL__Plugin } from './plugin'\n\ndescribe('the __MEITH_EXTENSION_KEY__ plugin', () => {\n it('has a validated manifest (definePlugin threw at import time otherwise)', () => {\n expect(__MEITH_EXTENSION_CAMEL__Plugin.key).toBe('__MEITH_EXTENSION_KEY__')\n expect(__MEITH_EXTENSION_CAMEL__Plugin.version).toBe('0.1.0')\n })\n\n it('appends its footer link without disturbing the board’s own', () => {\n const filter = __MEITH_EXTENSION_CAMEL__Plugin.hooks?.['view.footer'] as FilterHandler<'view.footer'>\n const footer = {\n boardTitle: 'A board',\n links: [{ label: 'Contact', href: '/contact' }],\n timezoneLabel: 'Europe/Dublin',\n }\n\n const filtered = filter(footer, { userId: null, isGuest: true, requestId: null })\n\n expect(filtered).toMatchObject({\n boardTitle: 'A board',\n links: [\n { label: 'Contact', href: '/contact' },\n { label: '__MEITH_EXTENSION_TITLE__ plugin', href: expect.stringContaining('__MEITH_EXTENSION_REPOSITORY__') },\n ],\n })\n expect(footer.links).toHaveLength(1)\n })\n})\n",
"import { describe, expect, it } from 'vitest'\n\nimport { type FilterHandler, unavailableHookRuntime } from '@meith/plugin-kit'\n\nimport { __MEITH_EXTENSION_CAMEL__Plugin } from './plugin'\n\ndescribe('the __MEITH_EXTENSION_KEY__ plugin', () => {\n it('has a validated manifest (definePlugin threw at import time otherwise)', () => {\n expect(__MEITH_EXTENSION_CAMEL__Plugin.key).toBe('__MEITH_EXTENSION_KEY__')\n expect(__MEITH_EXTENSION_CAMEL__Plugin.version).toBe('0.1.0')\n })\n\n it('appends its footer link without disturbing the board’s own', () => {\n const filter = __MEITH_EXTENSION_CAMEL__Plugin.hooks?.['view.footer'] as FilterHandler<'view.footer'>\n const footer = {\n boardTitle: 'A board',\n links: [{ label: 'Contact', href: '/contact' }],\n timezoneLabel: 'Europe/Dublin',\n }\n\n const filtered = filter(\n footer,\n { userId: null, isGuest: true, requestId: null },\n unavailableHookRuntime('this test drives the filter directly'),\n )\n\n expect(filtered).toMatchObject({\n boardTitle: 'A board',\n links: [\n { label: 'Contact', href: '/contact' },\n { label: '__MEITH_EXTENSION_TITLE__ plugin', href: expect.stringContaining('__MEITH_EXTENSION_REPOSITORY__') },\n ],\n })\n expect(footer.links).toHaveLength(1)\n })\n})\n",
},
]

Expand Down
Loading