Skip to content

feat(plugin-kit): let a hook handler reach its plugin's runtime - #262

Merged
jouwdan merged 1 commit into
mainfrom
claude/mei-115-xt6308-hook-runtime
Aug 26, 2026
Merged

feat(plugin-kit): let a hook handler reach its plugin's runtime#262
jouwdan merged 1 commit into
mainfrom
claude/mei-115-xt6308-hook-runtime

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Part of MEI-115. This is a prerequisite discovered while designing the first-cohort webhooks plugin, so it lands before the plugins that need it.

The gap

A hook handler is called with the value and the hook's own context — nothing else. It has no data, no notify, no logger. So a plugin can filter a view or observe an event in memory, but it cannot record a row, queue a delivery, or raise a notification: it cannot react to the board durably at all.

That is not a corner case. It is the most obvious thing a plugin author expects to be able to do, and the evidence it bites is already in the tree — plugins/dues declares no hooks whatsoever and works entirely through routes, tasks and pages, and plugins/reference records hook calls into an in-memory array. The webhooks, RSS, analytics and calendar plugins on MEI-115's cohort list all need it.

The change

A third argument, a function that resolves this plugin's runtime context:

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

Why a function rather than the context itself. 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 pure view filters that dominate pay for none of it. It is also honest about failure: on a fixture-mode board there is no database and await runtime() rejects with a message saying so, rather than handing back something that pretends. Within a single handler call it resolves once and is reused.

The reach is the same one everything else 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, counted, and subject to auto-disable.

Additive, so a minor and not a break. A two-argument handler still compiles and runs — verified: the only compile error across the whole tree was one call site in the example's test, not a single handler definition. Tests driving a handler directly pass unavailableHookRuntime(reason), whose every capability refuses with that reason.

Two structural fixes this required

Wiring the app's provider into the host exposed a real import cycle: plugin-host → the runtime builder → notificationsplugin-viewplugin-host. It surfaced as plugin-admin.test.ts failing to mock @meith/db.

  • The runtime builder moves out of plugin-pages.ts into its own plugin-runtime.ts, so the host does not pull in the page-rendering graph.
  • notifyFor defers its ./notifications import to the moment a plugin actually sends, which breaks the cycle and keeps the notification service off the hook path until used.

Validated

  • pnpm verify fully green: 7948 tests, 452 files.
  • 5 new host tests cover it: each plugin gets its own runtime, it is not built for a handler that does not ask, it is built once per call however often a handler asks, filters get it too, and a host built without a provider fails with a clear message and counts the failure like any other.
  • plugins/reference — the ratchet — now exercises it on post.created.
  • Documented in docs/customization/plugins.md; the scaffold templates regenerated from the updated example.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G22SRRgkUV7doefmw9NMNf


Generated by Claude Code

A handler was called with the value and the hook's context and nothing
else, so a plugin could filter a view or observe an event in memory but
could not record, enqueue or notify. Reacting to board activity — the
most obvious thing a plugin author expects to do — was not possible:
plugins/dues works around it by declaring no hooks at all.

Handlers now take a third argument, a function resolving this plugin's
runtime context: the same settings, logger, data, grants, users and
notify a task or route is handed, with the same limits. It is a function
because hooks are the hot path and a handler that never calls it costs
nothing, and because acquiring it can fail on a fixture-mode board, where
it rejects rather than pretending. Within one call it resolves once.

Additive: a two-argument handler still compiles and runs, so this is a
minor rather than a break. Callers driving a handler directly in a test
pass unavailableHookRuntime(reason).

Extracting the app's runtime builder into plugin-runtime.ts keeps the
host off the page-rendering graph, and deferring the notification
service's import breaks the notifications -> plugin-view -> plugin-host
cycle that reaching for it would otherwise create.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G22SRRgkUV7doefmw9NMNf
@jouwdan
jouwdan merged commit b2c72e0 into main Aug 26, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants