Skip to content

feat(plugins): plugin-system overhaul foundation — mount primitive, typed hooks, capabilities (Phases 1–3)#841

Open
lane711 wants to merge 4 commits into
mainfrom
lane711/plugin-system-issues
Open

feat(plugins): plugin-system overhaul foundation — mount primitive, typed hooks, capabilities (Phases 1–3)#841
lane711 wants to merge 4 commits into
mainfrom
lane711/plugin-system-issues

Conversation

@lane711
Copy link
Copy Markdown
Collaborator

@lane711 lane711 commented Jun 1, 2026

Summary

Foundation for the plugin-system overhaul. Replaces the hand-wired, partially-functional plugin wiring with tested primitives, and brings the hook system to life. Behavior-safe: the only user-visible change is that previously-dead plugin routes now mount; the hook/capability/singleton layers are live but inert (no dispatch sites yet), so nothing else changes at runtime.

Full design rationale (incl. Strapi v5 / Payload v3 deep dives) is in PLUGIN_SYSTEM_OVERHAUL_PLAN.md.

What was broken

Plugins declared routes/hooks via PluginBuilder, but nothing generically applied them. Every core plugin was hand-wired into app.ts, each guarded by a "MUST be registered BEFORE /admin/plugins" comment, so any plugin relying on addRoute() (global-variables, shortcodes) was never mounted and 404'd in production. The hook system had the same disease: HookSystemImpl only lived inside a PluginManager that's never instantiated, so plugin hooks were dead metadata too.

What's in this PR (Phases 1–3)

Phase 1 — route mount primitive (fixes #758, #829, #621)

Phase 2 / 2b — typed hooks + two-phase wiring

  • plugins/hooks/catalog.ts (typed event→payload map), typed-hooks.ts (on/dispatch with inference), hook-system-singleton.ts (env-independent access for cron), wire.ts (wireRegisteredPlugins + once-guard).
  • createSonicJSApp now publishes the hook system singleton at construction and runs a once-guarded first-request wiring pass (subscribe hooks + onBoot), error-isolated and skipped under disableAll.

Phase 3 — capabilities + service singletons

  • plugins/capabilities.ts: capability vocabulary + SonicCapabilityError + createCapabilityContext() whose accessors are lazy throwing getters (ctx.email throws without email:send) — the isolation boundary Strapi/Payload lack.
  • plugins/singletons/service-singleton.ts: generic createServiceSingleton<T>() (throw-before-get, idempotent set, cron-reachable).

Tests

+42 tests across mount, mount-integration, typed-hooks, wire, wire-integration, capabilities. Includes a tsc-validated type-level block proving hook payloads are narrowed and unknown events/fields are rejected, the #758 regression, the disableAll matrix, once-guard-under-concurrency, and capability gating.

  • Full core suite: 1540 passed, 0 failed (was 1498 pre-PR; 328 pre-existing skips)
  • tsc --noEmit: clean

Deliberately deferred (follow-ups)

  • Real dispatch() sites in content/auth routes + definePlugin() + email reference migration (Phase 5 — changes behavior).
  • Cron surface (Phase 4); refactor: parse plugin service rows into typed shapes #834 (Zod-parse PluginService rows).
  • Migrating the remaining ~20 core plugins onto the new model (Phase 6).

Closes #758. Unblocks #829, #621.

🤖 Generated with Claude Code

lane711 and others added 4 commits June 1, 2026 15:15
Replace the hand-wired, position-sensitive plugin route mounting in app.ts
with a shared, synchronous primitive. Plugins previously had to be wired into
core app.ts by hand, each guarded by a "MUST be registered BEFORE /admin/plugins"
comment, so any plugin relying on PluginBuilder.addRoute() (e.g. global-variables,
shortcodes) was never mounted and 404'd in production.

- Add plugins/mount.ts: registerPluginRoutes() + mountPlugin() +
  PluginRegisterMustBeSyncError. Synchronous (Hono's SmartRouter locks after the
  first request), priority-ordered, with duplicate-path warnings. Typed against a
  structural MountablePlugin to sidestep the src-vs-dist Plugin identity clash.
- app.ts: the 7 copy-pasted route-mounting blocks become two registerPluginRoutes()
  calls. Mount globalVariablesPlugin + shortcodesPlugin (fixes #758). Mount
  config.plugins.register user plugins before the /admin catch-all so consumers
  never edit core (#829, #621).
- disableAll now consistently gates all plugin mounting (core + user), matching
  bootstrap behavior and the documented intent; resolves the #829 review mismatch.
- types.ts: add sync-only Plugin.register?(app). app.ts: add
  SonicJSConfig.plugins.register; deprecate directory/autoLoad no-ops.

Tests: mount.test.ts (16 unit incl. sync-guard + catch-all shadowing) and
mount-integration.test.ts (5 incl. #758 regression + disableAll matrix).
Full core suite 1498 passed, 0 failed; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e 2)

Hooks were dead metadata at runtime: HookSystemImpl only exists inside
PluginManager, which is never instantiated in the running app, and nothing
dispatches lifecycle events. This lands the typed-hook + async-wiring foundation
so plugin hooks and onBoot can be wired for real (the async half of two-phase
boot; route mounting is the sync half from Phase 1).

- plugins/hooks/catalog.ts: typed event catalog (6 content + 3 auth events) ->
  payload types; HookEventName / HookPayload<E> / HOOK_EVENT_NAMES / isKnownHookEvent.
- plugins/hooks/typed-hooks.ts: createTypedHooks() -> { on<E>, dispatch<E> } with
  catalog inference; structural HookSystemLike so HookSystemImpl/ScopedHookSystem
  both satisfy it without casts.
- plugins/hooks/hook-system-singleton.ts: get/set/has/reset + getTypedHooks.
  Throw-before-get, idempotent set (multi-app/test safe), reset for isolation;
  env-independent access for cron (Phase 4).
- plugins/wire.ts: wireRegisteredPlugins() subscribes all hooks[] then runs each
  onBoot (per-plugin error isolation); structural WirablePlugin; createPluginWirer
  once-guard for the lazy first-request trigger.

Tests: typed-hooks.test.ts (11 runtime + a tsc-validated type-level block proving
narrowed payloads and rejected unknown events/fields), wire.test.ts (8: subscribe
-> dispatch, all-hooks-before-any-onBoot ordering, error isolation, once-guard
under concurrency). Full core suite 1517 passed, 0 failed; tsc clean.

Activating the wiring in the live app (eager setHookSystem, first-request wire,
real dispatch sites) is deferred to Phase 2b with dedicated integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the hook system to life. Previously hooks were dead metadata at runtime;
now createSonicJSApp wires plugins for real:

- Eager setHookSystem(new HookSystemImpl()) at construction publishes the
  singleton (env-independent access for cron later).
- Core plugins extracted into shared corePluginsBeforeCatchAll /
  corePluginsAfterCatchAll arrays, reused for both route mounting and wiring so
  they never drift (no Plugin[] annotation -> dodges the src/dist Plugin identity
  clash; both consumers are structural).
- A once-guarded first-request middleware (after bootstrap) runs
  wireRegisteredPlugins exactly once: subscribes every core + user plugin's
  hooks[] and runs onBoot. Error-isolated; skipped under disableAll.

The first request now subscribes the real core plugin hooks. They stay inert
until dispatch sites are added, so no existing behavior changes -- but the
infrastructure is proven end-to-end.

Tests: wire-integration.test.ts (4): singleton published at construction; first
request runs onBoot + subscribes hooks (verified by dispatching content:create);
wires exactly once across 3 requests; disableAll skips wiring. Full core suite
1521 passed, 0 failed; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the isolation boundary that Strapi (namespacing only) and Payload (full
config access) lack: a plugin declares the capabilities it needs, and the host
hands it a context whose powerful accessors are gated by those declarations.

- plugins/capabilities.ts: Phase 1 vocabulary (FIXED_CAPABILITIES + parameterized
  db:<table>), isKnownCapability/validateCapabilities, SonicCapabilityError,
  hasCapability/assertCapability, and createCapabilityContext() whose accessors are
  lazy throwing getters -- ctx.email throws SonicCapabilityError unless email:send
  was declared.
- plugins/singletons/service-singleton.ts: createServiceSingleton<T>(label)
  generalizing the hook-system-singleton pattern (throw-before-get, idempotent set,
  reset). Env-independent so cron/scheduled() handlers can reach services.

Pure infrastructure, no behavior change. Providers + gated context get wired into
the live app with definePlugin() in Phase 5.

Tests: capabilities.test.ts (19): db:<table> matching, granted/denied gating,
cache read-or-write, lazy providers, and the singleton contract (throw-before-get,
idempotent set, cron-reachable without env). Full core suite 1540 passed, 0 failed;
tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

bug: global-variables plugin routes not mounted after PR #743 merge

1 participant