core/plugin/wasmguest/describe.go
Alex Dunmow 04991295cf feat(abi,blocks): ninjatpl rendering as a host capability (powered blocks + tag/filter callbacks)
pongo2 stays host-side and is never compiled into a guest; plugins render
templates by handing the host a {template, data} pair, and the host calls
back into the free guest instance for plugin-declared tags/filters.

ABI (additive, buf-breaking clean):
- RenderBlockResponse gains a `powered` PoweredBlock{template, data_json};
  a block returns EITHER html OR powered.
- New hooks HOOK_RENDER_TAG (10) / HOOK_APPLY_FILTER (11) with
  RenderTag{Request,Response} and ApplyFilter{Request,Response}.
- PluginManifest gains repeated declared_tags / declared_filters (31/32).

Guest SDK (core/blocks, core/plugin/wasmguest):
- blocks.PoweredBlock(template, data) / DecodePoweredBlock: NUL-sentinel
  marker so BlockFunc's string signature is unchanged (smallest additive
  change — no ripple to existing blocks or the host guest-side).
- blocks.RegisterTag / RegisterFilter (+ RenderContext = context.Context)
  write a package-level registry; runRegister resets it per registration
  for deterministic DESCRIBE + dispatch.
- DESCRIBE emits declared_tags/filters; dispatch handles RENDER_TAG /
  APPLY_FILTER (fn errors → response.error; panics → AbiError INTERNAL,
  instance stays callable).

Docs: core/docs/wasm-abi.md gains the render-as-a-host-capability model,
the powered-block flow (re-entrancy-free), the plugin API, and the
HOST-SIDE CONTRACT the cms phase implements.

Tests: unit round-trips for powered/RENDER_TAG/APPLY_FILTER (dispatch +
error + unknown + panic + per-guest registry isolation) plus a real
wazero round-trip through the compiled fixture module. Verified no
guest-reachable package imports pongo2 (go list -deps on the wasip1
fixture build is clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 02:36:39 +08:00

333 lines
12 KiB
Go

package wasmguest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"connectrpc.com/connect"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/plugin/wasmguest/caps"
"git.dev.alexdunmow.com/block/core/rbac"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
)
// AbiVersion is the ABI major version this guest shim implements
// (PluginManifest.abi_version). See core/docs/wasm-abi.md for the
// compatibility rules.
const AbiVersion uint32 = 1
// describe handles HOOK_DESCRIBE: validate the caller's ABI version and
// return the static PluginManifest. Publish-time only — never called on a
// live instance.
func (g *guest) describe(payload []byte) (proto.Message, *abiv1.AbiError) {
req := &abiv1.DescribeRequest{}
if err := proto.Unmarshal(payload, req); err != nil {
return nil, decodeError("DescribeRequest", err)
}
// Symmetric version gate: a newer guest shim refuses a host it does not
// support, mirroring the host's manifest rejection.
if req.GetHostAbiVersion() != AbiVersion {
return nil, &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
Message: fmt.Sprintf("guest implements ABI major version %d; host declared %d",
AbiVersion, req.GetHostAbiVersion()),
}
}
return &abiv1.DescribeResponse{Manifest: g.buildManifest()}, nil
}
// buildManifest assembles the PluginManifest from the registration's static
// funcs plus the Register captures. Field-by-field mapping:
// core/docs/wasm-abi.md §"Manifest ↔ PluginRegistration mapping".
func (g *guest) buildManifest() *abiv1.PluginManifest {
reg := g.reg
m := &abiv1.PluginManifest{
AbiVersion: AbiVersion,
Name: reg.Name,
Version: reg.Version,
HasHttpHandler: reg.HTTPHandler != nil,
HasLoadHook: reg.Load != nil,
HasUnloadHook: reg.Unload != nil,
HasMediaHooks: reg.MediaHooks != nil,
HasProvisioner: reg.RegisterWithProvisioner != nil,
}
for _, d := range reg.Dependencies {
m.Dependencies = append(m.Dependencies, &abiv1.Dependency{
Plugin: d.Plugin, MinVersion: d.MinVersion, Required: d.Required,
})
}
// Register captures. BlockMeta.Source is omitted deliberately — the host
// assigns it from the manifest name at load time.
for _, meta := range g.blocks.metas {
m.Blocks = append(m.Blocks, &abiv1.BlockMeta{
Key: meta.Key,
Title: meta.Title,
Description: meta.Description,
Category: string(meta.Category),
HasInternalSlot: meta.HasInternalSlot,
Hidden: meta.Hidden,
EditorJs: meta.EditorJS,
})
}
for _, o := range g.blocks.overrides {
m.BlockTemplateOverrides = append(m.BlockTemplateOverrides, &abiv1.BlockTemplateOverride{
TemplateKey: o.templateKey, BlockKey: o.blockKey, Source: o.source,
})
}
m.TemplateKeys = append(m.TemplateKeys, g.templates.templateKeys...)
for _, st := range g.templates.systemTemplates {
m.SystemTemplates = append(m.SystemTemplates, &abiv1.SystemTemplateMeta{
Key: st.Key, Title: st.Title, Description: st.Description,
})
}
for _, pt := range g.templates.pageTemplates {
m.PageTemplates = append(m.PageTemplates, &abiv1.PageTemplateMeta{
SystemKey: pt.systemKey,
Key: pt.meta.Key,
Title: pt.meta.Title,
Description: pt.meta.Description,
Slots: pt.meta.Slots,
})
}
seenWrapper := make(map[string]bool)
for _, ew := range g.templates.emailWrappers {
if !seenWrapper[ew.systemKey] {
seenWrapper[ew.systemKey] = true
m.EmailWrapperSystemKeys = append(m.EmailWrapperSystemKeys, ew.systemKey)
}
}
// Static funcs.
if reg.AdminPages != nil {
for _, p := range reg.AdminPages() {
m.AdminPages = append(m.AdminPages, &abiv1.AdminPage{
Key: p.Key, Title: p.Title, Icon: p.Icon, Route: p.Route,
})
}
}
if reg.SettingsSchema != nil {
m.SettingsSchema = reg.SettingsSchema()
}
if reg.ThemePresets != nil {
m.ThemePresets = reg.ThemePresets()
}
if reg.BundledFonts != nil {
m.BundledFonts = reg.BundledFonts()
}
if reg.MasterPages != nil {
for _, mp := range reg.MasterPages() {
m.MasterPages = append(m.MasterPages, masterPageToProto(mp))
}
}
if reg.AIActions != nil {
for _, a := range reg.AIActions() {
m.AiActions = append(m.AiActions, &abiv1.AiAction{
Key: a.Key,
Title: a.Title,
Description: a.Description,
DefaultProvider: a.DefaultProvider,
DefaultModel: a.DefaultModel,
})
}
}
if reg.CSSManifest != nil {
if cm := reg.CSSManifest(); cm != nil {
m.CssManifest = &abiv1.CssManifest{
NpmPackages: cm.NPMPackages,
CssDirectives: cm.CSSDirectives,
InputCssAppend: cm.InputCSSAppend,
}
}
}
m.RequiredIconPacks = append(m.RequiredIconPacks, reg.RequiredIconPacks...)
// Custom template tags/filters the plugin registered via blocks.RegisterTag
// / blocks.RegisterFilter during the Register pass (runRegister reset the
// registry first, so these reflect exactly this plugin). The host wires a
// pongo2 tag/filter per name that calls back via HOOK_RENDER_TAG /
// HOOK_APPLY_FILTER.
m.DeclaredTags = blocks.RegisteredTagNames()
m.DeclaredFilters = blocks.RegisteredFilterNames()
if reg.DirectoryExtensions != nil {
if de := reg.DirectoryExtensions(); de != nil {
pde := &abiv1.DirectoryExtensions{
BooleanFilterFields: de.BooleanFilterFields,
SelectFilterFields: de.SelectFilterFields,
PanelSectionCount: uint32(len(de.PanelSections)),
PinDecoratorCount: uint32(len(de.PinDecorators)),
}
if len(de.BadgeLabels) > 0 {
pde.BadgeLabels = make(map[string]*abiv1.BadgeLabel, len(de.BadgeLabels))
for k, v := range de.BadgeLabels {
pde.BadgeLabels[k] = &abiv1.BadgeLabel{Positive: v[0], Negative: v[1]}
}
}
m.DirectoryExtensions = pde
}
}
if reg.SettingsPanel != nil {
m.SettingsPanel = reg.SettingsPanel()
}
// Function fields probed with capture-only CoreServices. These calls run
// without a live host, so panics from touching unwired capabilities are
// swallowed — whatever was declared before the panic is still captured.
m.JobTypes = g.captureJobTypes()
m.RagContentFetcherTypes = g.captureRagFetcherTypes()
g.captureServiceRegistration(m)
return m
}
// captureJobTypes enumerates JobHandlers keys with describe-time services.
func (g *guest) captureJobTypes() (types []string) {
if g.reg.JobHandlers == nil {
return nil
}
defer func() { recover() }() //nolint:errcheck // publish-time probe; partial capture is intended
handlers := g.reg.JobHandlers(describeServices())
types = sortedKeys(handlers)
return types
}
// captureRagFetcherTypes runs Load against capture-only services to record
// RAGService.RegisterContentFetcher declarations. DESCRIBE is publish-time
// only, so Load's runtime side effects have no host to land on; a panic
// (e.g. from an unwired capability) ends the probe but keeps prior
// registrations.
func (g *guest) captureRagFetcherTypes() (types []string) {
if g.reg.Load == nil {
return nil
}
rag := caps.NewRAGStub(nil)
svcs := describeServices()
svcs.RAGService = rag
func() {
defer func() { recover() }() //nolint:errcheck // publish-time probe
_ = g.reg.Load(svcs)
}()
return rag.FetcherTypes()
}
// captureServiceRegistration probes ServiceHandlers to record RBAC method
// roles and CoreServiceBindings.Bind declarations.
func (g *guest) captureServiceRegistration(m *abiv1.PluginManifest) {
if g.reg.ServiceHandlers == nil {
return
}
bindings := &captureCoreServiceBindings{}
svcs := describeServices()
svcs.CoreServiceBindings = bindings
var sr *plugin.ServiceRegistration
func() {
defer func() { recover() }() //nolint:errcheck // publish-time probe
reg, err := g.reg.ServiceHandlers(svcs)
if err == nil {
sr = reg
}
}()
m.CoreServiceBindings = append(m.CoreServiceBindings, bindings.bound...)
if sr == nil {
return
}
coreBound := make(map[string]bool, len(bindings.bound))
for _, b := range bindings.bound {
coreBound[b.GetServiceName()] = true
}
for _, svc := range sr.Services {
if coreBound[svc.Name()] {
continue // declared via core_service_bindings, host mounts it
}
for method, role := range svc.MethodRoles() {
if m.RbacMethodRoles == nil {
m.RbacMethodRoles = make(map[string]string)
}
m.RbacMethodRoles[method] = string(role)
}
}
}
// describeServices returns the CoreServices value used to probe registration
// funcs at publish time. It uses the capability stubs with a nil transport:
// there is no live host during DESCRIBE, so any capability a plugin invokes
// returns a clean "no host transport" error instead of nil-panicking, letting
// the probe capture whatever static registrations (jobs, RAG fetchers)
// surround the call. Callers still recover, belt-and-suspenders.
func describeServices() plugin.CoreServices {
return caps.NewCoreServices(nil)
}
func masterPageToProto(mp plugin.MasterPageDefinition) *abiv1.MasterPageDefinition {
out := &abiv1.MasterPageDefinition{
Key: mp.Key,
Title: mp.Title,
PageTemplates: mp.PageTemplates,
}
for _, b := range mp.Blocks {
pb := &abiv1.MasterPageBlock{
BlockKey: b.BlockKey,
Title: b.Title,
Slot: b.Slot,
SortOrder: b.SortOrder,
HtmlContent: b.HtmlContent,
}
if b.Content != nil {
if raw, err := json.Marshal(b.Content); err == nil {
pb.ContentJson = raw
}
}
out.Blocks = append(out.Blocks, pb)
}
return out
}
// --- capture / no-op stubs used only during DESCRIBE probes ---
type captureCoreServiceBindings struct {
bound []*abiv1.CoreServiceBinding
}
func (c *captureCoreServiceBindings) Bind(serviceName string, methodRoles map[string]rbac.Role) (plugin.ConnectServiceBinding, error) {
roles := make(map[string]string, len(methodRoles))
for k, v := range methodRoles {
roles[k] = string(v)
}
c.bound = append(c.bound, &abiv1.CoreServiceBinding{ServiceName: serviceName, MethodRoles: roles})
return plugin.NewConnectServiceBinding(serviceName, struct{}{},
func(_ struct{}, _ ...connect.HandlerOption) (string, http.Handler) {
return "/" + serviceName + "/", http.NotFoundHandler()
}, methodRoles), nil
}
// noopProvisioner satisfies plugin.Provisioner for the DESCRIBE-time
// Register pass; real provisioning runs host-side at load.
type noopProvisioner struct{}
func (noopProvisioner) EnsureDataTable(plugin.DataTableConfig) error { return nil }
func (noopProvisioner) MergeSiteSettings(map[string]any) error { return nil }
func (noopProvisioner) EnsureSetting(string, any) error { return nil }
func (noopProvisioner) EnsurePage(plugin.PageConfig) error { return nil }
func (noopProvisioner) OverrideSiteSettings(map[string]any) error { return nil }
func (noopProvisioner) EnsureMenuItem(string, plugin.MenuItemConfig) error { return nil }
func (noopProvisioner) RegisterEmbeddingConfig(plugin.EmbeddingConfigDef) error {
return nil
}
func (noopProvisioner) EnsureEmbed(plugin.EmbedConfig) error { return nil }
func (noopProvisioner) EnsureJobSchedule(plugin.JobScheduleConfig) error { return nil }
func (noopProvisioner) UpdateDataTableRowField(context.Context, uuid.UUID, string, any) error {
return nil
}
func (noopProvisioner) DisableOrphanedJobSchedules([]string) error { return nil }
func (noopProvisioner) EnsurePlugin(string) error { return nil }
func (noopProvisioner) EnsureCustomColor(plugin.CustomColorConfig) error { return nil }
func (noopProvisioner) EnsureMedia(plugin.MediaDeposit) error { return nil }