feat(plugins): plugin-system overhaul foundation — mount primitive, typed hooks, capabilities (Phases 1–3)#841
Open
lane711 wants to merge 4 commits into
Open
feat(plugins): plugin-system overhaul foundation — mount primitive, typed hooks, capabilities (Phases 1–3)#841lane711 wants to merge 4 commits into
lane711 wants to merge 4 commits into
Conversation
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>
This was referenced Jun 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 intoapp.ts, each guarded by a "MUST be registered BEFORE/admin/plugins" comment, so any plugin relying onaddRoute()(global-variables, shortcodes) was never mounted and 404'd in production. The hook system had the same disease:HookSystemImplonly lived inside aPluginManagerthat'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)
plugins/mount.ts: synchronous, position-awareregisterPluginRoutes()/mountPlugin()with aregister()-must-be-sync guard (Hono's SmartRouter locks after the first request).app.ts: 7 copy-pasted mounting blocks → the primitive; global-variables + shortcodes now mounted (bug: global-variables plugin routes not mounted after PR #743 merge #758);config.plugins.register: Plugin[]lets consumers add plugins with zero core edits (feat(core): explicit plugins.register API (replaces no-op autoLoad/directory) #829, Docs: document plugin registry generation step for create-sonicjs users #621).disableAllmade consistent — disables all plugins (core + user); code and docs now agree (resolves the feat(core): explicit plugins.register API (replaces no-op autoLoad/directory) #829 review mismatch).Phase 2 / 2b — typed hooks + two-phase wiring
plugins/hooks/catalog.ts(typed event→payload map),typed-hooks.ts(on/dispatchwith inference),hook-system-singleton.ts(env-independent access for cron),wire.ts(wireRegisteredPlugins+ once-guard).createSonicJSAppnow publishes the hook system singleton at construction and runs a once-guarded first-request wiring pass (subscribe hooks +onBoot), error-isolated and skipped underdisableAll.Phase 3 — capabilities + service singletons
plugins/capabilities.ts: capability vocabulary +SonicCapabilityError+createCapabilityContext()whose accessors are lazy throwing getters (ctx.emailthrows withoutemail:send) — the isolation boundary Strapi/Payload lack.plugins/singletons/service-singleton.ts: genericcreateServiceSingleton<T>()(throw-before-get, idempotent set, cron-reachable).Tests
+42 tests across
mount,mount-integration,typed-hooks,wire,wire-integration,capabilities. Includes atsc-validated type-level block proving hook payloads are narrowed and unknown events/fields are rejected, the #758 regression, thedisableAllmatrix, once-guard-under-concurrency, and capability gating.tsc --noEmit: cleanDeliberately deferred (follow-ups)
dispatch()sites in content/auth routes +definePlugin()+ email reference migration (Phase 5 — changes behavior).PluginServicerows).Closes #758. Unblocks #829, #621.
🤖 Generated with Claude Code