Skip to content

feat(core): explicit plugins.register API (replaces no-op autoLoad/directory)#829

Open
lane711 wants to merge 5 commits into
mainfrom
lane711/plugin-register-api
Open

feat(core): explicit plugins.register API (replaces no-op autoLoad/directory)#829
lane711 wants to merge 5 commits into
mainfrom
lane711/plugin-register-api

Conversation

@lane711
Copy link
Copy Markdown
Collaborator

@lane711 lane711 commented May 8, 2026

Summary

  • Adds plugins.register?: Plugin[] to SonicJSConfig so user plugins are mounted by createSonicJSApp directly — no more wrapping the core app in a custom Hono() instance just to attach plugin routes.
  • Marks plugins.directory / plugins.autoLoad as @deprecated no-ops. They were declared in the type but never wired up; bootstrap only iterated the hardcoded core plugin registry, so user plugins silently failed to load.
  • New mountPlugin() helper (packages/core/src/plugins/mount.ts) sorts middleware by priority, then mounts routes. User plugins are registered before /admin/plugins and the /admin catch-all so plugin admin pages aren't shadowed.

Why explicit, not filesystem autoload

SonicJS runs on Cloudflare Workers — no runtime fs, so any directory autoload would have to be a build-time Vite plugin. That's a lot of machinery for two lines of saved code, and most mature plugin ecosystems (Vite, Astro, Fastify, Nuxt modules) landed on explicit register: [...] arrays for the same reasons: type-safety, tree-shaking, debuggability, and the ability to pass config (weatherPlugin({ apiKey })) inline.

Originated from a Discord report: a user couldn't get a custom plugin to load with autoLoad: true. The flag did nothing.

Before / after (starter app)

// before — manual Hono wrapper, manual route loop
const coreApp = createSonicJSApp(config)
const app = new Hono()
if (contactFormPlugin.routes) {
  for (const route of contactFormPlugin.routes) app.route(route.path, route.handler)
}
app.route('/', coreApp)
export default app

// after
export default createSonicJSApp({
  collections: { autoSync: true },
  plugins: { register: [contactFormPlugin] },
})

Test plan

  • npx vitest run in packages/core — 1479 passed, 328 skipped, 0 failed
  • New mount.test.ts covers route mounting, multi-route, global vs scoped middleware, priority ordering, and empty-plugin no-op (6 tests)
  • npx tsc --noEmit in packages/core is clean
  • packages/stats still compiles (disableAll: true path unchanged)
  • Visual smoke test of starter template + a custom plugin in dev — recommended before merging

🤖 Generated with Claude Code

…rectory)

The previous `plugins.directory` + `autoLoad` config fields were declared
in the type but never wired up — bootstrap only iterated the hardcoded
core plugin registry, so user plugins silently failed to mount and the
starter app had to wrap the core app in its own Hono instance to attach
plugin routes.

Filesystem autoload is the wrong shape for this anyway: SonicJS targets
Cloudflare Workers, which has no runtime `fs`, and explicit registration
is what mature plugin ecosystems (Vite, Astro, Fastify, Nuxt modules)
landed on — type-safe, tree-shakeable, configurable, and easy to debug.

Changes:
- `SonicJSConfig.plugins.register?: Plugin[]` — pass plugin builds directly
- `mountPlugin()` helper sorts middleware by priority then mounts routes
- User plugins mount before the `/admin/*` catch-alls so plugin admin
  pages are not shadowed
- `directory` / `autoLoad` kept as `@deprecated` no-ops for one minor
- Starter template, in-repo example app, and docs updated

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ister

Exercises the full app pipeline (bootstrap, security headers, CSRF, admin
auth wall, user-plugin mounting, /admin catch-alls). Critically verifies
that a plugin route at /admin/plugins/<name>/* is NOT shadowed by the
core /admin/plugins catch-all — a 404 there would mean the ordering
broke.

Also updates the doc site:
- plugins/development/page.mdx Step 4 — explicit register array, not the
  old re-export pattern that didn't actually load anything
- faq/page.mdx — adds the register snippet next to the plugin creation
  example so the FAQ no longer leaves users hanging the way the
  Discord question did

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… subpath exports

Combines this branch's user-plugin work with the changes from furelid#1
(furelid#1). The two PRs solved different
problems and are complementary; this commit takes both.

From furelid #1:
- mountPluginManagerRoutes(app, plugins[], options) replaces the 9
  duplicated route-mount loops in createSonicJSApp. Each core plugin's
  routes are now gated by a per-request guard that checks `is_active` in
  the plugins table — flipping the toggle in the admin UI now actually
  affects route resolution.
- 60s TTL cache (pluginStatusCache) so the gate is one Map lookup on the
  hot path; one D1 read per plugin per minute amortized.
- isCorePlugin callback bypasses gating for plugins with is_core === true
  in their manifest (database-tools, seed-data, core-cache, etc.).
- plugins.enabled?: string[] for explicit allowlists when DB gating is
  disabled.
- @sonicjs-cms/core/plugins subpath now exports aiSearchPlugin,
  analyticsPlugin, globalVariablesPlugin, oauthProvidersPlugin,
  securityAuditPlugin, OAuthService, BUILT_IN_PROVIDERS, stripePlugin,
  shortcodesPlugin, etc. so advanced users can wrap or compose them.
- Tightens a few imports that were going through the public package
  re-export back to the source modules.

Kept from this branch:
- plugins.register?: Plugin[] for user plugins, mounted via mountPlugin()
  (no DB gating — if the user imported and registered it, they want it on).
- @deprecated marks on plugins.directory and plugins.autoLoad.
- Doc + blog post fixes.

User plugins still bypass the new gating system. Adding them to
mountPluginManagerRoutes with isCorePlugin: () => false is a small
follow-up so the admin "disable plugin" toggle applies to user plugins
too.

All 1486 unit tests pass (1479 mine + furelid + 2 plugin-route-mounting
+ 5 createSonicJSApp integration smoke tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- README.md: replace stale plugin example (createSonicJS-shaped, hooks
  object that doesn't match the real PluginHook[] type) with a real
  PluginBuilder + plugins.register snippet that compiles against today's
  exports.
- guides/sonicjs-plugins-extending-your-cms.mdx: rewrite the registration
  section, npm-consumer example, and a few aside-claims to use the actual
  createSonicJSApp + plugins.register API. Drops fictional createSonicJS,
  authPlugin()/emailPlugin()/etc. factory imports, and the "drop a file
  in src/plugins and SonicJS picks it up" claim — Workers has no runtime
  fs and there is no filesystem auto-discovery.
- Unskip 5 plugin e2e specs (15-plugins, 21-plugin-version-display,
  28-plugin-filters-search, 29-email-plugin-settings, 36-easymde-plugin-
  visible). 29 was previously skipped for D1-timing flakiness in CI;
  worth running it again now that the merged plugin-mounting changes
  exercise that path differently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Re-running the plugin e2e suite against this branch (after fixing the
node_modules symlink so the conductor workspace's core was actually
loaded) surfaced 13 false-404s caused by furelid #1's gating logic:

1. Five plugins (email, otp-login, security-audit, core-analytics,
   magic-link-auth) were added to mountPluginManagerRoutes in app.ts
   but never added to BOOTSTRAP_PLUGIN_IDS, so their plugins-table row
   was never created and isPluginActive(db, name) always returned false.
   Result: 404 on every admin/api request to those plugins.

2. ai-search-plugin and turnstile-plugin used `name: manifest.name`
   ("AI Search", "Cloudflare Turnstile") for the PluginBuilder, but
   the gate looks up by manifest.id. The mismatch denied every
   request even though both manifests have is_core: true.

Fixes:
- Add the five missing plugins to BOOTSTRAP_PLUGIN_IDS so they're
  installed + activated on first boot.
- Switch ai-search and turnstile to use manifest.id as the plugin name.
- Add bootstrap-coverage.test.ts asserting every plugin mounted via
  mountPluginManagerRoutes is also bootstrapped, plus the name===id
  invariant for the two plugins that broke it.
- Add /contact root-path regression test (plugin mounted at path: '/'
  with sub-path /contact must resolve, mirrors contact-form plugin).

Co-Authored-By: Claude Opus 4.7 <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.

1 participant