Alex Dunmow cb1acbab6b developing-blockninja-plugins: rewrite for the wasm-plugin era
.so pipeline is dead (cms a04277ee2): standalone plugins are reactor-mode
wasip1 wasm packed into .bnp artifacts. Skill now teaches ninja plugin
build/verify/publish --bnp, bnwasm sqlc DB (per-plugin role, own schema
only), capability dispositions, per-instance-state gotchas (HostServices,
deps stashing), sandbox constraints (data_dir, buffered HTTP, hot-swap),
with plugins/testplugin as the worked example. html-blocks.md: reword the
one .so compat line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:09:55 +08:00

15 KiB

name description
developing-blockninja-plugins Use when creating, modifying, building, or publishing BlockNinja CMS plugins or themes — work in plugins/* repos, plugin registration, blocks, templates, plugin Connect services, migrations, ninja plugin commands, check-safety for plugins, or block/core SDK usage in standalone plugin repos.

Developing BlockNinja Plugins

Overview

⚠️ If you remember .so instructions, they are obsolete. Standalone plugins are now wasm (wazero, WASI reactor mode) — the big-bang migration landed 2026-07-03 and the .so loader/builder were deleted from cms (so_loader.go, internal/builder/, cms commit a04277ee2). There is NO make build-so, NO plugin.Open, NO CGO, NO in-container compile, NO host/plugin Go-version lock-step, NO copy-plugin-source deploy, and NO go:embed'd web bundle. Instances download a prebuilt .bnp artifact — they never compile. A registry version published as a source archive is rejected at install ("legacy source archive; republish as .bnp").

Never write plugin code from memory — every SDK symbol is locally verifiable. Truth lives at:

  • SDK source: ~/src/blockninja/core — import prefix git.dev.alexdunmow.com/block/core/... is the ONLY one allowed; never block/cms/...
  • ABI contract (authoritative): ~/src/blockninja/core/docs/wasm-abi.md — reactor mode, hook catalog, capability dispositions, error codes, plugin.mod data_dir
  • Architecture spec: ~/src/blockninja/cms/docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md
  • Canonical guide (registration concepts, blocks, provisioning): ~/src/blockninja/cms/docs/PLUGIN_DEVELOPMENT.md
  • Exemplars: plugins/testplugin — THE reference wasm plugin, every surface + failure endpoints (see its README surface table); plugins/symposium — the largest real port (service-heavy: RPC, jobs, AI, templ blocks; its Makefile is the build/publish convention)

Core-vs-plugin: platform-wide behavior → core; domain-specific or owns its own data/UI → plugin (decision table in PLUGIN_DEVELOPMENT.md).

Anatomy of a wasm plugin

PluginRegistration is unchanged from the .so era — same fields, same Register, same CoreServices. What changed is how it's served. Boilerplate main.go (testplugin/main.go verbatim):

//go:build wasip1

package main

import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"

func init() { wasmguest.Serve(Registration) }

func main() {} // never called — reactor mode

Compile: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm . (Go ≥ 1.24; reactor mode — plain go build command mode does NOT work, the exports are never callable). You never run this by hand: ninja plugin build does it.

At publish time a DESCRIBE probe instantiates the module once and captures every static registration surface into manifest.pb; the CMS loader reads that without instantiating the module. Dynamic work (block render, HTTP, jobs, Load/Unload, RAG fetch, media hooks) crosses the ABI as protobuf hooks into a pool of instances (default 4, 512 MiB memory cap each, 30 s per-call deadline). Full hook catalog + manifest↔Registration field map: wasm-abi.md.

DESCRIBE constraint: host functions are stubbed to fail during the probe, so Register (and anything reached from it) must not call capabilities or the DB — it gets a named error at build time. Load-time work belongs in Load.

Worked example — plugins/testplugin

Surface File
Blocks (rendercontext, content) + template (testplugin_page) registration.go
Connect service via plugin.NewConnectServiceBinding (RBAC roles in manifest) service.go
Raw chi routes + failure endpoints (/panic, /slow, /forbidden-sql, /alloc) httphandler.go
Job handler, media hooks, Load/Unload, RAG fetcher, deps stashing registration.go
Goose migration + sqlc (pgx/v5) queries migrations/, sql/queries/, db/
Settings schema, theme preset (go:embed'd JSON) assets/
data_dir = true grant + /data round-trip plugin.mod, onLoad

Blocks: prefer the built-in html block

The default reflex — register a custom block type (blocks.BlockMeta + a func(ctx, content) string render func + a Module Federation editor) — is usually the wrong altitude for a section/content block. Most sections (hero, features, nav, footer, CTA, legal) are built-in html blocks: a pongo2 template rendered into _html_content, BlockKey: "html", zero Go render code and zero custom editor. Live html blocks even drive admin-editable menus and use context.*. Register a custom block only when you need a structured field-form editor or render logic a template can't express. Custom keys also risk red block-fallback boxes when a key isn't registered; built-in html never does. Decision guide, render modes, menu wiring, and gotchas: html-blocks.md.

Custom blocks that hit the DB have a wasm-specific gotcha — see "Per-instance state" below.

Database — sqlc unchanged, sandboxed role

sqlc-generated code (sql_package: "pgx/v5") works without source edits: at runtime deps.Pool is a bnwasm.Pool (pgx-shaped Pool/Tx over the ABI's db.* messages). Autocommit: type-assert the pool to your db.DBTX (registration.go openQueries). Transactions: deps.Pool.Begin(ctx)db.New(tx)Commit/Rollback (httphandler.go /db-tx). Errors surface as *pgconn.PgError with real SQLSTATEs.

Hard rules and limits:

  • Own schema ONLY. Queries run under a per-plugin Postgres role (plugin_<name>) granted only on schema <name>. public.* is permission denied (SQLSTATE 42501) by design — core data goes through CoreServices capability interfaces, never SQL.
  • No raw SQL — sqlc for static, Bob for dynamic (workspace rule, applies to plugins).
  • Savepoints/nested transactions and named args (pgx.NamedArgs) are rejected with clear errors.
  • text[] values cannot carry NULL elements (collapse to ""); the whole array can still be NULL.
  • A tx handle dies with the call chain's deadline: errors.Is(err, bnwasm.ErrTxExpired) is retryable — re-run the unit of work in a fresh transaction.
  • Migrations are plain Goose SQL in migrations/ — run by the host (never cross the boundary). db/*.sql.go is committed; regenerate with sqlc generate.

Capabilities — CoreServices works as before, know the dispositions

Plugin code compiles against the same Go interfaces (deps.Content, deps.Settings, deps.Crypto, deps.Menus, deps.RAGService, …) — the guest SDK ships them as stubs marshaling to host functions. Full family/method table + disposition of every CoreServices member: wasm-abi.md. The ones that behave differently:

  • Interceptors, CoreServiceBindingshost-side; RBAC method roles merge from the manifest, auth context arrives via forwarded HTTP headers (host interceptors already ran).
  • AppURL/MediaPath — delivered once in LoadRequest.host_config.
  • Bridge.GetService reports availability but returns nil (typed values can't cross — open item); RegisterService forwards names only.
  • RAGService.RegisterContentFetcher — records guest-side + a manifest declaration; the host calls back via HOOK_RAG_FETCH.
  • Methods without an error channel (Slugify, EvaluateAccess, Bridge.*, …) degrade to zero values on transport failure.

Per-instance state (the #1 wasm gotcha)

Concurrency = an instance pool; each instance is a separate Go runtime with its own globals. Load fires once, on one instance — globals it sets do NOT exist on the others. Two sanctioned patterns (both in testplugin/symposium):

  1. Stash deps in every deps-receiving entry point (Load, HTTPHandler, JobHandlers) so deps-less hooks (media hooks, Unload) can reach the DB — testplugin's pkgDeps/stashDeps. Instances are single-threaded, so a plain global is safe.
  2. DB-backed blocks: HOOK_RENDER_BLOCK receives no services. Get the pool inside Register (which runs on every instance) via wasmguest.HostServices().Poolnil-check it: in native/DESCRIBE builds it's the zero value. See symposium register.go.

Sandbox constraints — write reload-safe code

  • No filesystem, no network, no env in the guest. One opt-in exception: data_dir = true in plugin.mod grants a read-write preopen at guest /data — a per-plugin directory on a persistent volume (instance-<slug>-plugin-data), shared across the plugin's pool instances, deleted on uninstall. Off by default; the grant is visible at publish time. Absent at DESCRIBE time — tolerate the write failure (testplugin onLoad).
  • Buffered HTTP only — no SSE/WebSocket/streaming from plugin endpoints. Realtime features use the core realtime system (@block-ninja/ui hooks / core topics).
  • Panics recover to errors: an in-hook panic → INTERNAL error → 5xx; the host and other plugins survive. Deadline overruns and OOM discard the instance; the pool refills. Error-code table (INTERNAL/DECODE/UNIMPLEMENTED/DEADLINE/PERMISSION/TX_EXPIRED): wasm-abi.md.
  • Hot swap: plugin updates swap live (SwapPlugin) with no restart — never assume an instance lives long, or that in-memory state survives an update. Persist in the DB or /data.
  • No os.Getenv for secrets (nothing is there anyway) — plugin settings or CoreServices.

Build → verify → publish

plugin.mod is still the manifest (name/version/kind/scope/categories/tags, plus the new first-class data_dir bool). kind is still frozen at first publish. Pin a recent block/core; exact-version lock-step with the CMS is dead (testplugin builds on v0.15.1 while cms runs v0.15.2) — compatibility is gated by ABI major (abi_version, currently 1).

ninja plugin build --dir .            # wasip1 compile + DESCRIBE probe → <name>-<version>.bnp
ninja plugin verify <name>-<ver>.bnp  # loader-identical checks: layout, abi_version, name match
cd ~/src/blockninja/check-safety && go run . <plugin-path>   # MUST exit 0 (unchanged mandate)

ninja plugin bump patch               # commits plugin.mod — does NOT git-tag
git tag vX.Y.Z && git push origin main vX.Y.Z   # --follow-tags skips lightweight tags
ninja plugin build --dir .            # rebuild at the bumped version
ninja plugin publish --bnp <name>-<ver>.bnp     # --bnp is REQUIRED: without it publish
                                      # ships a source archive, which instances REJECT

The .bnp (tar.zst) packs plugin.wasm, plugin.mod, manifest.pb, plus migrations/, schemas/, assets/, and web/dist (flattened under web/) when present. Statics are consumed host-side: Goose runs migrations, assets are served directly, and the Module Federation admin bundle is served from the artifact at /plugins/<name>/no longer go:embed'd, but web/dist must be built (cd web && pnpm run build) before ninja plugin build. Makefile convention: copy plugins/symposium/Makefile (build / build-frontend / verify / archive-check / publish — archive-check now proves git archive HEAD wasm-compiles). Generated Go (*_templ.go, db/*.sql.go, *connect.go) and web/dist stay committed; .gitignore adds *.bnp and *.wasm.

Dev loop: publish to the DEV orchestrator (ninja --host https://my.localdev.blockninjacms.com plugin publish --bnp …my.blockninjacms.com without localdev is PRODUCTION). Install via Admin → Plugins → Browse Registry (InstallFromRegistry): the instance downloads the .bnp, checksum-verifies, and hot-loads without restart; updates hot-swap the same way. First publish lands private; review via the orchestrator dashboard. Private account-scoped plugins: --private on init/publish.

Verifying SDK symbols

Before using an unfamiliar SDK call, check: (1) the pinned SDK — go doc git.dev.alexdunmow.com/block/core/plugin CoreServices from the plugin dir; (2) the capability disposition table in core/docs/wasm-abi.md (does it cross the ABI, and how?); (3) nearest exemplar usage in testplugin/symposium; (4) the CMS-side implementation in cms/backend for semantics.

Missing capability ⇒ two sanctioned paths only: extend block/core (guest stub + host function + ABI proto — additive within ABI major 1), or vendor the cms-internal package into the plugin's internal/ with a provenance header. NEVER replace directives; NEVER block/cms imports. Remember the wasm boundary: a new capability that returns functions/handlers can't cross — it needs a hook or a manifest declaration instead.

Doc routing

Working on Read first
ABI, hooks, capabilities, error codes, data_dir, build/pack internals core/docs/wasm-abi.md
Scaffold, registration, custom block types, CoreServices concepts cms/docs/PLUGIN_DEVELOPMENT.md
Section/content blocks — html block vs custom; menus; live vs pre-render html-blocks.md (in this skill) — read BEFORE writing a custom block type
Depositing media (seed EnsureMedia / runtime CoreServices.Media) PLUGIN_DEVELOPMENT.md §Depositing Media
Plugin migrations / DB tables — own schema, per-plugin role PLUGIN_DEVELOPMENT.md §Migrations + this skill §Database
Platform data tables (Provisioner.EnsureDataTable, View-rootable JSONB store) PLUGIN_DEVELOPMENT.md §Provisioning → Data tables
Public HTTP routes (webhooks, widgets) — buffered only cms/docs/PLUGIN_HTTP_HANDLERS.md
Load/Unload, runtime state cms/docs/PLUGIN_LIFECYCLE_HOOKS.md + this skill §Per-instance state
Themes, templates, master pages, CSS cms/docs/TEMPLATE_PLUGINS.md
Theme preview screenshots (ninja theme screenshot, registry previewImageUrl) cms/docs/theme-previews.md
Block editor / settings UI (Module Federation) cms/docs/PLUGIN_EDITOR_SDK.md
Migration rationale, what stayed/died the 2026-07-03 wasm migration spec (cms/docs/superpowers/specs)

Stale docs — do NOT follow: cms/docs/compiled-plugin-architecture.md (.so pipeline, dead) and any plugins/CLAUDE.md / memory notes describing make build-so, in-container compiles, go:embed'd web bundles, or core-version skew.

Release traps

  • ninja plugin publish without --bnp ships a source archive — the registry accepts it but every instance install fails with "legacy source archive; republish as .bnp". Always build first and pass --bnp (or make publish).
  • kind is frozen at first publish. The registry stores kind (plugin|theme) on first CreatePlugin and never updates it; publish only compares. A theme MUST declare kind = "theme" BEFORE its first publish. Dev-only remedy for a misregistered row: UPDATE registry_plugins SET kind='theme' on orchestrator-db, then republish.
  • ninja plugin publish ships metadata from git HEAD (README/CHANGELOG, warnings on dirty tree) and the artifact from --bnp — keep the tree committed at the bumped version so the two agree; make archive-check proves HEAD compiles.
  • ninja plugin init is interactive and DROPS unknown plugin.mod keys on rewrite — re-check plugin.mod after running it (data_dir is a known key and survives).

New plugin: ninja plugin init, copy main.go + layout from testplugin and the Makefile from symposium, minimal registration per PLUGIN_DEVELOPMENT.md.