diff --git a/docs/wasm-abi.md b/docs/wasm-abi.md index fc4085b..1ae8b19 100644 --- a/docs/wasm-abi.md +++ b/docs/wasm-abi.md @@ -26,6 +26,41 @@ so it lives repo-local. wire-visible requires a major bump. `buf breaking` (FILE rules, configured in `abi/buf.yaml`) enforces this against the previous commit. +## Module lifecycle — REACTOR mode (`_initialize`, no `_start`) + +Plugins compile as WASI **reactors** (Go ≥ 1.24 toolchain for +`go:wasmexport`; this repo's floor is higher — check `go.mod`): + +``` +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm . +``` + +with one boilerplate main file next to the untouched Registration: + +```go +//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 +``` + +A reactor module exports `_initialize` instead of `_start`. The host MUST +run `_initialize` exactly once per instance **before any `bn_invoke`** +(wazero: `ModuleConfig.WithStartFunctions("_initialize")`); it runs package +init funcs — hence `Serve`, which stores the registration and returns. +`main` exists only to satisfy the linker and is never called. + +> Command mode (plain `go build`) does NOT work and must not be used +> (empirically verified, Go 1.26 + wazero v1.12.0, 2026-07-03): `_start` +> runs `main` synchronously, so a blocking `main` (`select{}`) trips the Go +> deadlock detector and traps, while a returning `main` exits and closes +> the module — either way the exports are never callable. The original +> blocking-main design was amended to reactor mode for this reason. + ## Calling convention (ptr+len, packed u64) wasm exports can only pass `i32/i64/f32/f64`, so all payloads cross as @@ -37,10 +72,26 @@ protobuf bytes in guest linear memory. The guest exports (via | `bn_alloc` | `(size: u32) → ptr: u32` | Host asks the guest to allocate `size` bytes in guest memory. The returned region stays valid until the current `bn_invoke` call returns. | | `bn_invoke` | `(hook_id: u32, ptr: u32, len: u32) → packed: u64` | Host writes a serialized `InvokeRequest` at `(ptr, len)` (memory from `bn_alloc`) and calls with `hook_id = Hook` enum value (duplicated in the envelope for decode sanity). The return packs the response location: `packed = (ptr << 32) | len`, framing a serialized `InvokeResponse` in guest memory, valid until the next `bn_invoke` on this instance. | -Host functions (guest→host capability calls, module `env`) use the same -shape in reverse: the guest passes `(ptr, len)` framing a serialized -`HostCallRequest`; the host returns a packed `u64` framing a -`HostCallResponse` that it wrote into guest memory via `bn_alloc`. +Host functions (guest→host capability calls) live in wasm import module +**`blockninja`** and use the same shape in reverse. There is exactly ONE +generic import rather than one symbol per capability family: + +``` +(blockninja) host_call(ptr: u32, len: u32) → packed: u64 +``` + +The guest passes `(ptr, len)` framing a serialized `HostCallRequest` (whose +`method` string — `"."`, including the `db.*` driver +methods — already selects the family); the host returns a packed `u64` +framing a `HostCallResponse` that it wrote into guest memory via `bn_alloc`. +The guest shim releases that buffer after decoding, so the host must not +reuse it. Per-family import symbols were considered and rejected (decision, +WO-WZ-002): they would add ~40 declarations on both sides for zero type +safety, since the payloads are opaque protobuf bytes either way. + +Buffers MUST come from `bn_alloc` on both paths — the guest rejects a +`bn_invoke` request pointer it did not hand out (`ABI_ERROR_CODE_DECODE`), +and treats an unknown host-call response pointer the same way. A `packed` value of `0` means the callee could not even produce an envelope (allocation failure / trap); the caller treats it as diff --git a/plugin/wasmguest/context.go b/plugin/wasmguest/context.go new file mode 100644 index 0000000..d06d3b0 --- /dev/null +++ b/plugin/wasmguest/context.go @@ -0,0 +1,225 @@ +package wasmguest + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + + abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" + "git.dev.alexdunmow.com/block/core/blocks" + "github.com/google/uuid" +) + +// contextFromRenderContext rebuilds the context.Context that block and +// template code expects, applying each RenderContext field through the same +// context keys core/blocks/context.go defines. Existing render code reading +// via blocks.Get* works unchanged inside the guest. +// +// Function-valued context entries (SlotRenderer, MediaResolver, +// EmbedResolver, Queries) do not cross the wire; their v1 mappings are +// documented in core/docs/wasm-abi.md. RenderContext.theme_json and .locale +// have no core/blocks context key yet and are ignored here until a consumer +// key exists. +func contextFromRenderContext(ctx context.Context, rc *abiv1.RenderContext) context.Context { + if rc == nil { + return ctx + } + + if req := requestFromInfo(rc.GetRequest()); req != nil { + ctx = blocks.WithRequest(ctx, req) + } + if bc := blockContextFromProto(rc.GetBlockContext()); bc != nil { + ctx = blocks.WithBlockContext(ctx, bc) + } + if rc.GetTemplateKey() != "" { + ctx = blocks.WithTemplateKey(ctx, rc.GetTemplateKey()) + } + if p := rc.GetPage(); p != nil { + ctx = blocks.WithCurrentPage(ctx, &blocks.PageContext{ + ID: parseUUID(p.GetId()), + Slug: p.GetSlug(), + Title: p.GetTitle(), + PostType: p.GetPostType(), + Status: p.GetStatus(), + }) + } + if p := rc.GetPost(); p != nil { + post := &blocks.PostContext{ + ID: parseUUID(p.GetId()), + Slug: p.GetSlug(), + Title: p.GetTitle(), + Excerpt: p.GetExcerpt(), + FeaturedImageURL: p.GetFeaturedImageUrl(), + AuthorID: parseUUID(p.GetAuthorId()), + ReadingTime: int(p.GetReadingTime()), + IsFeatured: p.GetIsFeatured(), + } + if ts := p.GetPublishedAt(); ts != nil { + post.PublishedAt = ts.AsTime() + } + ctx = blocks.WithCurrentBlogPost(ctx, post) + } + if a := rc.GetAuthor(); a != nil { + ctx = blocks.WithCurrentAuthor(ctx, &blocks.AuthorContext{ + ID: parseUUID(a.GetId()), + Name: a.GetName(), + Slug: a.GetSlug(), + Bio: a.GetBio(), + AvatarURL: a.GetAvatarUrl(), + }) + } + if c := rc.GetCategory(); c != nil { + ctx = blocks.WithCurrentCategory(ctx, &blocks.CategoryContext{ + ID: parseUUID(c.GetId()), + Name: c.GetName(), + Slug: c.GetSlug(), + }) + } + if mp := rc.GetMasterPage(); mp != nil { + ctx = blocks.WithMasterPage(ctx, &blocks.MasterPageContext{ + ID: parseUUID(mp.GetId()), + Slug: mp.GetSlug(), + Title: mp.GetTitle(), + }) + } + if rc.GetRequestedPath() != "" { + ctx = blocks.WithRequestedPath(ctx, rc.GetRequestedPath()) + } + if slots := rc.GetInjectedSlots(); len(slots) > 0 { + ctx = blocks.WithInjectedSlots(ctx, slots) + } + if rc.GetIsEditor() { + ctx = blocks.WithIsEditor(ctx, true) + } + if slots := rc.GetExpectedSlots(); len(slots) > 0 { + ctx = blocks.WithExpectedSlots(ctx, slots) + } + if id := parseUUID(rc.GetBlockId()); id != uuid.Nil { + ctx = blocks.WithBlockID(ctx, id) + } + if id := parseUUID(rc.GetCurrentPageId()); id != uuid.Nil { + ctx = blocks.WithCurrentPageID(ctx, id) + } + if hp := rc.GetHumanProofBanner(); hp != nil { + ctx = blocks.WithHumanProofBanner(ctx, &blocks.HumanProofBannerData{ + ActiveTimeMinutes: int(hp.GetActiveTimeMinutes()), + KeystrokeCount: int(hp.GetKeystrokeCount()), + SessionCount: int(hp.GetSessionCount()), + PostSlug: hp.GetPostSlug(), + }) + } + if dr := rc.GetDetailRow(); dr != nil { + ctx = blocks.WithDetailRow(ctx, dr.GetTableId(), dr.GetRowId(), unmarshalMap(dr.GetDataJson())) + } + return ctx +} + +// requestFromInfo rebuilds a *http.Request carrying the subset of fields +// render code reads via blocks.GetRequest. +func requestFromInfo(ri *abiv1.RequestInfo) *http.Request { + if ri == nil { + return nil + } + u, err := url.Parse(ri.GetUrl()) + if err != nil || ri.GetUrl() == "" { + u = &url.URL{Path: ri.GetPath(), RawQuery: ri.GetRawQuery()} + } + req := &http.Request{ + Method: ri.GetMethod(), + URL: u, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header, len(ri.GetHeaders())+2), + Host: ri.GetHost(), + RemoteAddr: ri.GetRemoteIp(), + RequestURI: u.RequestURI(), + } + for k, v := range ri.GetHeaders() { + req.Header.Set(k, v) + } + if ri.GetReferrer() != "" && req.Header.Get("Referer") == "" { + req.Header.Set("Referer", ri.GetReferrer()) + } + if ri.GetUserAgent() != "" && req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", ri.GetUserAgent()) + } + for name, value := range ri.GetCookies() { + req.AddCookie(&http.Cookie{Name: name, Value: value}) + } + return req +} + +func blockContextFromProto(pb *abiv1.BlockContext) *blocks.BlockContext { + if pb == nil { + return nil + } + bc := &blocks.BlockContext{ + URL: pb.GetUrl(), + Path: pb.GetPath(), + Slug: pb.GetSlug(), + PageID: pb.GetPageId(), + PageTitle: pb.GetPageTitle(), + TemplateKey: pb.GetTemplateKey(), + IsEditor: pb.GetIsEditor(), + Timestamp: pb.GetTimestamp(), + IsLoggedIn: pb.GetIsLoggedIn(), + UserID: pb.GetUserId(), + UserEmail: pb.GetUserEmail(), + UserRole: pb.GetUserRole(), + Method: pb.GetMethod(), + Host: pb.GetHost(), + Query: pb.GetQuery(), + Referrer: pb.GetReferrer(), + UserAgent: pb.GetUserAgent(), + IP: pb.GetIp(), + Cookies: pb.GetCookies(), + Headers: pb.GetHeaders(), + Country: pb.GetCountry(), + City: pb.GetCity(), + Timezone: pb.GetTimezone(), + CurrentAuthor: unmarshalMap(pb.GetCurrentAuthorJson()), + CurrentPost: unmarshalMap(pb.GetCurrentPostJson()), + CurrentCategory: unmarshalMap(pb.GetCurrentCategoryJson()), + Site: unmarshalMap(pb.GetSiteJson()), + IsPublicLoggedIn: pb.GetIsPublicLoggedIn(), + PublicUserID: pb.GetPublicUserId(), + PublicUsername: pb.GetPublicUsername(), + PublicDisplayName: pb.GetPublicDisplayName(), + PublicEmailVerified: pb.GetPublicEmailVerified(), + DetailRowID: pb.GetDetailRowId(), + DetailTableID: pb.GetDetailTableId(), + DetailRowData: unmarshalMap(pb.GetDetailRowDataJson()), + BlogIndexURL: pb.GetBlogIndexUrl(), + CategoryPageURL: pb.GetCategoryPageUrl(), + } + if ts := pb.GetNow(); ts != nil { + bc.Now = ts.AsTime() + } + return bc +} + +// unmarshalMap decodes a JSON object into map[string]any, returning nil for +// empty or undecodable input (render code treats absent maps as nil). +func unmarshalMap(raw []byte) map[string]any { + if len(raw) == 0 { + return nil + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil + } + return m +} + +func parseUUID(s string) uuid.UUID { + if s == "" { + return uuid.Nil + } + id, err := uuid.Parse(s) + if err != nil { + return uuid.Nil + } + return id +} diff --git a/plugin/wasmguest/describe.go b/plugin/wasmguest/describe.go new file mode 100644 index 0000000..935233c --- /dev/null +++ b/plugin/wasmguest/describe.go @@ -0,0 +1,327 @@ +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/ai" + "git.dev.alexdunmow.com/block/core/plugin" + "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...) + 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 := &guestRAGService{fetchers: make(map[string]plugin.ContentFetcher)} + svcs := describeServices() + svcs.RAGService = rag + func() { + defer func() { recover() }() //nolint:errcheck // publish-time probe + _ = g.reg.Load(svcs) + }() + return sortedKeys(rag.fetchers) +} + +// 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: only side-effect-free capture stubs +// are populated. Anything else a plugin touches during the probe panics and +// is recovered by the caller. +func describeServices() plugin.CoreServices { + return plugin.CoreServices{ + ToolRegistry: noopToolRegistry{}, + } +} + +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 +} + +type noopToolRegistry struct{} + +func (noopToolRegistry) Register(*ai.ToolDefinition) {} + +// 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 } diff --git a/plugin/wasmguest/dispatch.go b/plugin/wasmguest/dispatch.go new file mode 100644 index 0000000..c6c9d4a --- /dev/null +++ b/plugin/wasmguest/dispatch.go @@ -0,0 +1,438 @@ +package wasmguest + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "time" + + abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" + "git.dev.alexdunmow.com/block/core/plugin" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" +) + +// current is the guest runtime installed by Serve. The bn_invoke export +// (exports.go, wasip1) dispatches through it. +var current *guest + +// Serve installs a plugin registration as the wasm guest runtime. It is +// non-blocking: plugins call it from an init func (see doc.go) so it runs +// during the module's `_initialize`; all work then arrives via bn_invoke. +func Serve(reg plugin.PluginRegistration) { + current = newGuest(reg) +} + +// guest adapts a plugin.PluginRegistration to ABI hook calls. +type guest struct { + reg plugin.PluginRegistration + blocks *captureBlockRegistry + templates *captureTemplateRegistry + registerErr error + + // services is the CoreServices value handed to the registration's + // function fields (HTTPHandler, JobHandlers, Load). The capability-stub + // WO populates its interface fields with host_call-backed + // implementations; LOAD fills AppURL/MediaPath from HostConfig. + services plugin.CoreServices + + // ragFetchers records RAGService.RegisterContentFetcher calls made + // during LOAD, dispatched via HOOK_RAG_FETCH. + ragFetchers map[string]plugin.ContentFetcher + + jobHandlers map[string]plugin.JobHandlerFunc + jobHandlersInit bool + httpHandler http.Handler + httpHandlerInit bool +} + +// newGuest captures the registration's Register pass so block/template +// functions are addressable before any hook fires. +func newGuest(reg plugin.PluginRegistration) *guest { + g := &guest{ + reg: reg, + blocks: newCaptureBlockRegistry(), + templates: newCaptureTemplateRegistry(), + ragFetchers: make(map[string]plugin.ContentFetcher), + } + g.services.RAGService = &guestRAGService{fetchers: g.ragFetchers} + g.registerErr = runRegister(reg, g.templates, g.blocks) + return g +} + +// runRegister invokes Register (or RegisterWithProvisioner with a no-op +// provisioner — provisioning itself runs host-side at load) against capture +// registries, converting panics to errors. +func runRegister(reg plugin.PluginRegistration, tr *captureTemplateRegistry, br *captureBlockRegistry) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic in Register: %v", r) + } + }() + // Match the .so loader: block keys are plugin-prefixed and override + // sources stamped via PluginBlockRegistry. + pbr := plugin.NewPluginBlockRegistry(br, reg.Name) + switch { + case reg.Register != nil: + return reg.Register(tr, pbr) + case reg.RegisterWithProvisioner != nil: + return reg.RegisterWithProvisioner(tr, pbr, noopProvisioner{}) + default: + return nil + } +} + +// invoke handles one bn_invoke call: (hook id, serialized InvokeRequest) → +// serialized InvokeResponse. It never panics — plugin-level panics come back +// as ABI_ERROR_CODE_INTERNAL so the instance stays usable; traps are +// reserved for runtime corruption. +func (g *guest) invoke(hookID uint32, req []byte) (out []byte) { + defer func() { + if r := recover(); r != nil { + out = errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, fmt.Sprintf("panic: %v", r)) + } + }() + + env := &abiv1.InvokeRequest{} + if err := proto.Unmarshal(req, env); err != nil { + return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE, "invalid InvokeRequest: "+err.Error()) + } + if uint32(env.GetHook()) != hookID { + return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE, + fmt.Sprintf("hook id mismatch: export arg %d, envelope %d", hookID, env.GetHook())) + } + if g.registerErr != nil { + return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, "plugin Register failed: "+g.registerErr.Error()) + } + + ctx := context.Background() + if d := env.GetDeadlineMs(); d > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(d)*time.Millisecond) + defer cancel() + } + + var ( + payload proto.Message + abiErr *abiv1.AbiError + ) + switch env.GetHook() { + case abiv1.Hook_HOOK_DESCRIBE: + payload, abiErr = g.describe(env.GetPayload()) + case abiv1.Hook_HOOK_RENDER_BLOCK: + payload, abiErr = g.renderBlock(ctx, env.GetPayload()) + case abiv1.Hook_HOOK_RENDER_TEMPLATE: + payload, abiErr = g.renderTemplate(ctx, env.GetPayload()) + case abiv1.Hook_HOOK_HANDLE_HTTP: + payload, abiErr = g.handleHTTP(ctx, env.GetPayload()) + case abiv1.Hook_HOOK_JOB: + payload, abiErr = g.runJob(ctx, env.GetPayload()) + case abiv1.Hook_HOOK_LOAD: + payload, abiErr = g.load(env.GetPayload()) + case abiv1.Hook_HOOK_UNLOAD: + payload, abiErr = g.unload(ctx) + case abiv1.Hook_HOOK_RAG_FETCH: + payload, abiErr = g.ragFetch(ctx, env.GetPayload()) + case abiv1.Hook_HOOK_MEDIA_HOOK: + payload, abiErr = g.mediaHook(ctx, env.GetPayload()) + default: + abiErr = &abiv1.AbiError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, + Message: fmt.Sprintf("unknown hook %d", env.GetHook()), + } + } + + resp := &abiv1.InvokeResponse{Error: abiErr} + if abiErr == nil && payload != nil { + b, err := proto.Marshal(payload) + if err != nil { + return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, "marshal response payload: "+err.Error()) + } + resp.Payload = b + } + b, err := proto.Marshal(resp) + if err != nil { + return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, "marshal InvokeResponse: "+err.Error()) + } + return b +} + +// --- hook handlers --- + +func (g *guest) renderBlock(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) { + req := &abiv1.RenderBlockRequest{} + if err := proto.Unmarshal(payload, req); err != nil { + return nil, decodeError("RenderBlockRequest", err) + } + fn, ok := g.blocks.lookup(req.GetBlockKey(), req.GetRenderContext().GetTemplateKey()) + if !ok { + return nil, &abiv1.AbiError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, + Message: "unknown block key " + req.GetBlockKey(), + } + } + ctx = contextFromRenderContext(ctx, req.GetRenderContext()) + content := unmarshalMap(req.GetContentJson()) + if content == nil { + content = map[string]any{} + } + return &abiv1.RenderBlockResponse{Html: fn(ctx, content)}, nil +} + +func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) { + req := &abiv1.RenderTemplateRequest{} + if err := proto.Unmarshal(payload, req); err != nil { + return nil, decodeError("RenderTemplateRequest", err) + } + fn, ok := g.templates.lookup(req.GetTemplateKey()) + if !ok { + return nil, &abiv1.AbiError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, + Message: "unknown template key " + req.GetTemplateKey(), + } + } + ctx = contextFromRenderContext(ctx, req.GetRenderContext()) + doc := unmarshalMap(req.GetDocJson()) + if doc == nil { + doc = map[string]any{} + } + component := fn(ctx, doc) + if component == nil { + return nil, internalError("template " + req.GetTemplateKey() + " returned nil component") + } + var buf bytes.Buffer + if err := component.Render(ctx, &buf); err != nil { + return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error()) + } + return &abiv1.RenderTemplateResponse{Html: buf.Bytes()}, nil +} + +func (g *guest) handleHTTP(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) { + if g.reg.HTTPHandler == nil { + return nil, &abiv1.AbiError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, + Message: "plugin has no HTTP handler", + } + } + req := &abiv1.HttpRequest{} + if err := proto.Unmarshal(payload, req); err != nil { + return nil, decodeError("HttpRequest", err) + } + if !g.httpHandlerInit { + g.httpHandler = g.reg.HTTPHandler(g.services) + g.httpHandlerInit = true + } + if g.httpHandler == nil { + return nil, internalError("HTTPHandler returned nil handler") + } + + httpReq, err := http.NewRequestWithContext(ctx, req.GetMethod(), + (&url.URL{Path: req.GetPath(), RawQuery: req.GetRawQuery()}).String(), + bytes.NewReader(req.GetBody())) + if err != nil { + return nil, internalError("build http request: " + err.Error()) + } + for name, hv := range req.GetHeaders() { + for _, v := range hv.GetValues() { + httpReq.Header.Add(name, v) + } + } + + rec := httptest.NewRecorder() + g.httpHandler.ServeHTTP(rec, httpReq) + res := rec.Result() + + out := &abiv1.HttpResponse{ + Status: int32(res.StatusCode), + Headers: make(map[string]*abiv1.HeaderValues, len(res.Header)), + Body: rec.Body.Bytes(), + } + for name, values := range res.Header { + out.Headers[name] = &abiv1.HeaderValues{Values: values} + } + return out, nil +} + +func (g *guest) runJob(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) { + req := &abiv1.JobRequest{} + if err := proto.Unmarshal(payload, req); err != nil { + return nil, decodeError("JobRequest", err) + } + if !g.jobHandlersInit { + if g.reg.JobHandlers != nil { + g.jobHandlers = g.reg.JobHandlers(g.services) + } + g.jobHandlersInit = true + } + handler, ok := g.jobHandlers[req.GetJobType()] + if !ok { + return nil, &abiv1.AbiError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, + Message: "unknown job type " + req.GetJobType(), + } + } + // Job progress reporting needs a jobs.progress host function — an ABI + // open item (core/docs/wasm-abi.md); v1 discards progress updates. + progress := func(current, total int, message string) {} + result, err := handler(ctx, req.GetConfigJson(), progress) + if err != nil { + return nil, internalError(err.Error()) + } + return &abiv1.JobResponse{ResultJson: result}, nil +} + +func (g *guest) load(payload []byte) (proto.Message, *abiv1.AbiError) { + req := &abiv1.LoadRequest{} + if err := proto.Unmarshal(payload, req); err != nil { + return nil, decodeError("LoadRequest", err) + } + if hc := req.GetHostConfig(); hc != nil { + g.services.AppURL = hc.GetAppUrl() + g.services.MediaPath = hc.GetMediaPath() + } + if g.reg.Load != nil { + if err := g.reg.Load(g.services); err != nil { + return nil, internalError(err.Error()) + } + } + return &abiv1.LoadResponse{}, nil +} + +func (g *guest) unload(ctx context.Context) (proto.Message, *abiv1.AbiError) { + if g.reg.Unload != nil { + if err := g.reg.Unload(ctx); err != nil { + return nil, internalError(err.Error()) + } + } + return &abiv1.UnloadResponse{}, nil +} + +func (g *guest) ragFetch(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) { + req := &abiv1.RagFetchRequest{} + if err := proto.Unmarshal(payload, req); err != nil { + return nil, decodeError("RagFetchRequest", err) + } + fetcher, ok := g.ragFetchers[req.GetContentType()] + if !ok { + return nil, &abiv1.AbiError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, + Message: "no content fetcher for type " + req.GetContentType(), + } + } + contentID, err := uuid.Parse(req.GetContentId()) + if err != nil { + return nil, decodeError("RagFetchRequest.content_id", err) + } + title, text, err := fetcher(ctx, contentID) + if err != nil { + return nil, internalError(err.Error()) + } + return &abiv1.RagFetchResponse{Title: title, Text: text}, nil +} + +func (g *guest) mediaHook(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) { + if g.reg.MediaHooks == nil { + return nil, &abiv1.AbiError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED, + Message: "plugin has no media hooks", + } + } + req := &abiv1.MediaHookRequest{} + if err := proto.Unmarshal(payload, req); err != nil { + return nil, decodeError("MediaHookRequest", err) + } + var err error + switch ev := req.GetEvent().(type) { + case *abiv1.MediaHookRequest_MediaAnalyzed: + e := ev.MediaAnalyzed + err = g.reg.MediaHooks.OnMediaAnalyzed(ctx, plugin.MediaAnalyzedEvent{ + MediaID: parseUUID(e.GetMediaId()), + AnalysisID: parseUUID(e.GetAnalysisId()), + ContentHash: e.GetContentHash(), + Status: e.GetStatus(), + SourcePlugin: e.GetSourcePlugin(), + SourceType: e.GetSourceType(), + SourceRefID: parseUUID(e.GetSourceRefId()), + SafeAdult: e.GetSafeAdult(), + SafeViolence: e.GetSafeViolence(), + SafeRacy: e.GetSafeRacy(), + }) + case *abiv1.MediaHookRequest_ModerationDecision: + e := ev.ModerationDecision + err = g.reg.MediaHooks.OnModerationDecision(ctx, plugin.ModerationDecisionEvent{ + MediaID: parseUUID(e.GetMediaId()), + AnalysisID: parseUUID(e.GetAnalysisId()), + Status: e.GetStatus(), + PreviousStatus: e.GetPreviousStatus(), + SourcePlugin: e.GetSourcePlugin(), + SourceType: e.GetSourceType(), + SourceRefID: parseUUID(e.GetSourceRefId()), + ModeratedBy: parseUUID(e.GetModeratedBy()), + Note: e.GetNote(), + }) + default: + return nil, decodeError("MediaHookRequest.event", fmt.Errorf("empty oneof")) + } + if err != nil { + return nil, internalError(err.Error()) + } + return &abiv1.MediaHookResponse{}, nil +} + +// --- error helpers --- + +func internalError(msg string) *abiv1.AbiError { + return &abiv1.AbiError{Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, Message: msg} +} + +func decodeError(what string, err error) *abiv1.AbiError { + return &abiv1.AbiError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE, + Message: "decode " + what + ": " + err.Error(), + } +} + +// errorResponse builds a serialized error-only InvokeResponse. Marshaling a +// flat AbiError cannot fail; if the runtime is corrupted enough that it +// does, returning nil makes bn_invoke return packed 0, which the host treats +// as INTERNAL + instance discard (per wasm-abi.md). +func errorResponse(code abiv1.AbiErrorCode, msg string) []byte { + b, err := proto.Marshal(&abiv1.InvokeResponse{Error: &abiv1.AbiError{Code: code, Message: msg}}) + if err != nil { + return nil + } + return b +} + +// --- guest-side RAG service (fetcher capture) --- + +// guestRAGService records content-fetcher registrations so HOOK_RAG_FETCH +// can dispatch to them. Query/OnContentChanged become host capability calls +// in the capability-stub WO; until then they report unimplemented. +type guestRAGService struct { + fetchers map[string]plugin.ContentFetcher +} + +func (s *guestRAGService) RegisterContentFetcher(contentType string, fetcher plugin.ContentFetcher) { + s.fetchers[contentType] = fetcher +} + +func (s *guestRAGService) Query(context.Context, string, int) ([]plugin.RAGResult, error) { + return nil, fmt.Errorf("wasmguest: rag.query host capability not wired yet") +} + +func (s *guestRAGService) OnContentChanged(context.Context, string, uuid.UUID) {} + +// sortedKeys returns the map's keys in sorted order (deterministic manifests). +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/plugin/wasmguest/dispatch_test.go b/plugin/wasmguest/dispatch_test.go new file mode 100644 index 0000000..78c5d25 --- /dev/null +++ b/plugin/wasmguest/dispatch_test.go @@ -0,0 +1,371 @@ +package wasmguest + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + + 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/templates" + "google.golang.org/protobuf/proto" +) + +// --- test fixture registration --- + +type textComponent string + +func (c textComponent) Render(_ context.Context, w io.Writer) error { + _, err := io.WriteString(w, string(c)) + return err +} + +func fixtureRegistration() plugin.PluginRegistration { + return plugin.PluginRegistration{ + Name: "fixture", + Version: "0.1.0", + Dependencies: []plugin.Dependency{ + {Plugin: "other", MinVersion: "1.2.3", Required: true}, + }, + Register: func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error { + br.Register(blocks.BlockMeta{ + Key: "hello", + Title: "Hello Block", + Description: "Says hello", + Category: blocks.CategoryContent, + }, func(ctx context.Context, content map[string]any) string { + if boom, _ := content["boom"].(bool); boom { + panic("kaboom") + } + name, _ := content["name"].(string) + page := blocks.GetCurrentPage(ctx) + pageSlug := "" + if page != nil { + pageSlug = page.Slug + } + return fmt.Sprintf("

hello %s on %s (tpl %s)

", name, pageSlug, blocks.GetTemplateKey(ctx)) + }) + br.RegisterTemplateOverride("landing", "hello", func(ctx context.Context, content map[string]any) string { + return "

override hello

" + }) + tr.RegisterSystemTemplate(templates.SystemTemplateMeta{Key: "fixture-sys", Title: "Fixture Theme"}) + if err := tr.Register("fixture-tpl", func(ctx context.Context, doc map[string]any) templates.HTMLComponent { + title, _ := doc["title"].(string) + return textComponent("" + title + "") + }); err != nil { + return err + } + return tr.RegisterPageTemplate("fixture-sys", templates.PageTemplateMeta{ + Key: "fixture-page", Title: "Fixture Page", Slots: []string{"main"}, + }, func(ctx context.Context, doc map[string]any) templates.HTMLComponent { + return textComponent("") + }) + }, + AdminPages: func() []plugin.AdminPage { + return []plugin.AdminPage{{Key: "fixture", Title: "Fixture Admin", Icon: "puzzle", Route: "/admin/fixture"}} + }, + AIActions: func() []plugin.AIAction { + return []plugin.AIAction{{Key: "fixture.summarize", Title: "Summarize", DefaultProvider: "openai", DefaultModel: "gpt-4o"}} + }, + SettingsSchema: func() []byte { return []byte(`{"type":"object"}`) }, + JobHandlers: func(deps plugin.CoreServices) map[string]plugin.JobHandlerFunc { + return map[string]plugin.JobHandlerFunc{ + "fixture_job": func(ctx context.Context, config json.RawMessage, progress func(int, int, string)) (json.RawMessage, error) { + return json.RawMessage(`{"ok":true}`), nil + }, + } + }, + Load: func(deps plugin.CoreServices) error { return nil }, + Unload: func(ctx context.Context) error { return nil }, + RequiredIconPacks: []string{"tabler"}, + } +} + +// --- helpers --- + +func invokeHook(t *testing.T, g *guest, hook abiv1.Hook, payload proto.Message) *abiv1.InvokeResponse { + t.Helper() + var payloadBytes []byte + if payload != nil { + b, err := proto.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + payloadBytes = b + } + req, err := proto.Marshal(&abiv1.InvokeRequest{Hook: hook, Payload: payloadBytes}) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + out := g.invoke(uint32(hook), req) + resp := &abiv1.InvokeResponse{} + if err := proto.Unmarshal(out, resp); err != nil { + t.Fatalf("unmarshal InvokeResponse: %v", err) + } + return resp +} + +// --- tests --- + +func TestServeInstallsRuntime(t *testing.T) { + t.Cleanup(func() { current = nil }) + Serve(fixtureRegistration()) + if current == nil { + t.Fatal("Serve did not install the guest runtime") + } + resp := invokeHook(t, current, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1}) + if resp.GetError() != nil { + t.Fatalf("describe via Serve-installed runtime failed: %v", resp.GetError()) + } +} + +func TestDescribeManifest(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1}) + if resp.GetError() != nil { + t.Fatalf("describe returned error: %v", resp.GetError()) + } + dr := &abiv1.DescribeResponse{} + if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil { + t.Fatalf("unmarshal DescribeResponse: %v", err) + } + m := dr.GetManifest() + if m == nil { + t.Fatal("nil manifest") + } + if m.GetAbiVersion() != 1 { + t.Errorf("abi_version = %d, want 1", m.GetAbiVersion()) + } + if m.GetName() != "fixture" || m.GetVersion() != "0.1.0" { + t.Errorf("name/version = %q/%q", m.GetName(), m.GetVersion()) + } + if len(m.GetDependencies()) != 1 || m.GetDependencies()[0].GetPlugin() != "other" { + t.Errorf("dependencies = %v", m.GetDependencies()) + } + if len(m.GetBlocks()) != 1 || m.GetBlocks()[0].GetKey() != "fixture:hello" { + t.Fatalf("blocks = %v, want one block fixture:hello", m.GetBlocks()) + } + if m.GetBlocks()[0].GetCategory() != "content" { + t.Errorf("block category = %q", m.GetBlocks()[0].GetCategory()) + } + if len(m.GetBlockTemplateOverrides()) != 1 || + m.GetBlockTemplateOverrides()[0].GetTemplateKey() != "landing" || + m.GetBlockTemplateOverrides()[0].GetBlockKey() != "hello" || + m.GetBlockTemplateOverrides()[0].GetSource() != "fixture" { + t.Errorf("overrides = %v", m.GetBlockTemplateOverrides()) + } + if len(m.GetTemplateKeys()) != 1 || m.GetTemplateKeys()[0] != "fixture-tpl" { + t.Errorf("template_keys = %v", m.GetTemplateKeys()) + } + if len(m.GetSystemTemplates()) != 1 || m.GetSystemTemplates()[0].GetKey() != "fixture-sys" { + t.Errorf("system_templates = %v", m.GetSystemTemplates()) + } + if len(m.GetPageTemplates()) != 1 || m.GetPageTemplates()[0].GetSystemKey() != "fixture-sys" || + m.GetPageTemplates()[0].GetKey() != "fixture-page" { + t.Errorf("page_templates = %v", m.GetPageTemplates()) + } + if len(m.GetAdminPages()) != 1 || m.GetAdminPages()[0].GetRoute() != "/admin/fixture" { + t.Errorf("admin_pages = %v", m.GetAdminPages()) + } + if len(m.GetAiActions()) != 1 || m.GetAiActions()[0].GetKey() != "fixture.summarize" { + t.Errorf("ai_actions = %v", m.GetAiActions()) + } + if string(m.GetSettingsSchema()) != `{"type":"object"}` { + t.Errorf("settings_schema = %s", m.GetSettingsSchema()) + } + if len(m.GetJobTypes()) != 1 || m.GetJobTypes()[0] != "fixture_job" { + t.Errorf("job_types = %v", m.GetJobTypes()) + } + if !m.GetHasLoadHook() || !m.GetHasUnloadHook() { + t.Errorf("has_load_hook/has_unload_hook = %v/%v, want true/true", m.GetHasLoadHook(), m.GetHasUnloadHook()) + } + if m.GetHasHttpHandler() || m.GetHasMediaHooks() || m.GetHasProvisioner() { + t.Errorf("unexpected presence flags: http=%v media=%v prov=%v", + m.GetHasHttpHandler(), m.GetHasMediaHooks(), m.GetHasProvisioner()) + } + if len(m.GetRequiredIconPacks()) != 1 || m.GetRequiredIconPacks()[0] != "tabler" { + t.Errorf("required_icon_packs = %v", m.GetRequiredIconPacks()) + } +} + +func TestDescribeRejectsUnknownHostVersion(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 99}) + if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED { + t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError()) + } +} + +func TestUnknownHook(t *testing.T) { + g := newGuest(fixtureRegistration()) + req, _ := proto.Marshal(&abiv1.InvokeRequest{Hook: abiv1.Hook(99)}) + out := g.invoke(99, req) + resp := &abiv1.InvokeResponse{} + if err := proto.Unmarshal(out, resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED { + t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError()) + } +} + +func TestHookIDEnvelopeMismatch(t *testing.T) { + g := newGuest(fixtureRegistration()) + req, _ := proto.Marshal(&abiv1.InvokeRequest{Hook: abiv1.Hook_HOOK_DESCRIBE}) + out := g.invoke(uint32(abiv1.Hook_HOOK_RENDER_BLOCK), req) + resp := &abiv1.InvokeResponse{} + if err := proto.Unmarshal(out, resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE { + t.Fatalf("error = %v, want DECODE", resp.GetError()) + } +} + +func TestUndecodableEnvelope(t *testing.T) { + g := newGuest(fixtureRegistration()) + out := g.invoke(uint32(abiv1.Hook_HOOK_DESCRIBE), []byte{0xff, 0xff, 0xff, 0xff}) + resp := &abiv1.InvokeResponse{} + if err := proto.Unmarshal(out, resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE { + t.Fatalf("error = %v, want DECODE", resp.GetError()) + } +} + +func TestRenderBlock(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{ + BlockKey: "fixture:hello", + ContentJson: []byte(`{"name":"world"}`), + RenderContext: &abiv1.RenderContext{ + TemplateKey: "fixture-tpl", + Page: &abiv1.PageContext{ + Id: "5f0c5f3f-3f1e-4a3a-9b6e-2f4f6a6d7e8f", + Slug: "home", Title: "Home", PostType: "page", Status: "published", + }, + }, + }) + if resp.GetError() != nil { + t.Fatalf("render error: %v", resp.GetError()) + } + rr := &abiv1.RenderBlockResponse{} + if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil { + t.Fatalf("unmarshal RenderBlockResponse: %v", err) + } + want := "

hello world on home (tpl fixture-tpl)

" + if rr.GetHtml() != want { + t.Errorf("html = %q, want %q", rr.GetHtml(), want) + } +} + +func TestRenderBlockTemplateOverride(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{ + BlockKey: "hello", + ContentJson: []byte(`{}`), + RenderContext: &abiv1.RenderContext{TemplateKey: "landing"}, + }) + if resp.GetError() != nil { + t.Fatalf("render error: %v", resp.GetError()) + } + rr := &abiv1.RenderBlockResponse{} + if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if rr.GetHtml() != "

override hello

" { + t.Errorf("html = %q", rr.GetHtml()) + } +} + +func TestRenderBlockUnknownKey(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{ + BlockKey: "nope", ContentJson: []byte(`{}`), + }) + if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED { + t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError()) + } +} + +func TestPanickingBlockReturnsAbiErrorAndStaysCallable(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{ + BlockKey: "fixture:hello", ContentJson: []byte(`{"boom":true}`), + }) + e := resp.GetError() + if e.GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL { + t.Fatalf("error = %v, want INTERNAL", e) + } + if !strings.Contains(e.GetMessage(), "kaboom") { + t.Errorf("error message %q does not mention panic value", e.GetMessage()) + } + // The guest must remain callable after a recovered panic. + resp2 := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{ + BlockKey: "fixture:hello", ContentJson: []byte(`{"name":"again"}`), + }) + if resp2.GetError() != nil { + t.Fatalf("second render failed: %v", resp2.GetError()) + } + rr := &abiv1.RenderBlockResponse{} + if err := proto.Unmarshal(resp2.GetPayload(), rr); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !strings.Contains(rr.GetHtml(), "again") { + t.Errorf("html = %q", rr.GetHtml()) + } +} + +func TestRenderTemplate(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{ + TemplateKey: "fixture-tpl", + DocJson: []byte(`{"title":"My Page"}`), + }) + if resp.GetError() != nil { + t.Fatalf("render error: %v", resp.GetError()) + } + rr := &abiv1.RenderTemplateResponse{} + if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if string(rr.GetHtml()) != "My Page" { + t.Errorf("html = %q", rr.GetHtml()) + } +} + +func TestJobHook(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_JOB, &abiv1.JobRequest{ + JobType: "fixture_job", ConfigJson: []byte(`{}`), + }) + if resp.GetError() != nil { + t.Fatalf("job error: %v", resp.GetError()) + } + jr := &abiv1.JobResponse{} + if err := proto.Unmarshal(resp.GetPayload(), jr); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if string(jr.GetResultJson()) != `{"ok":true}` { + t.Errorf("result = %s", jr.GetResultJson()) + } +} + +func TestLoadUnloadHooks(t *testing.T) { + g := newGuest(fixtureRegistration()) + resp := invokeHook(t, g, abiv1.Hook_HOOK_LOAD, &abiv1.LoadRequest{ + HostConfig: &abiv1.HostConfig{AppUrl: "https://example.test", MediaPath: "/media"}, + }) + if resp.GetError() != nil { + t.Fatalf("load error: %v", resp.GetError()) + } + resp = invokeHook(t, g, abiv1.Hook_HOOK_UNLOAD, &abiv1.UnloadRequest{}) + if resp.GetError() != nil { + t.Fatalf("unload error: %v", resp.GetError()) + } +} diff --git a/plugin/wasmguest/doc.go b/plugin/wasmguest/doc.go new file mode 100644 index 0000000..dce73ca --- /dev/null +++ b/plugin/wasmguest/doc.go @@ -0,0 +1,46 @@ +// Package wasmguest is the guest half of the BlockNinja wasm plugin ABI: +// the go:wasmexport entry points (bn_alloc / bn_invoke / bn_free), guest +// memory pinning, the generic host-call import, and the dispatch table that +// adapts an unmodified plugin.PluginRegistration to ABI hook calls. +// +// The wire contract lives in core/docs/wasm-abi.md; the messages in +// git.dev.alexdunmow.com/block/core/abi/v1. +// +// # Plugin boilerplate +// +// A plugin becomes a wasm plugin by adding one main file next to its +// Registration: +// +// //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 +// +// and compiling in REACTOR mode (Go >= 1.24 toolchain; this repo's floor is +// higher): +// +// GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm . +// +// Reactor modules export `_initialize` instead of `_start`: the host runs +// `_initialize` once per instance — which runs the init func above, hence +// Serve — before any bn_invoke. Serve is non-blocking: it stores the +// registration, runs the Register pass against capture registries, and +// returns; all work then arrives through bn_invoke. +// +// Command mode (plain `go build`) does NOT work and must not be used: its +// `_start` runs main synchronously, so a blocking main deadlocks the Go +// runtime ("all goroutines are asleep") and a returning main exits and +// closes the module — either way the exports are never callable +// (empirically verified against wazero v1.12.0; see wasm-abi.md). +// +// # Build shape +// +// Dispatch, describe, capture-registry, and context-reconstruction logic is +// plain Go and builds on every GOOS so it stays natively testable. Only +// exports.go (go:wasmexport, memory pinning) and hostcalls.go +// (go:wasmimport blockninja host_call) carry the wasip1 build tag. +package wasmguest diff --git a/plugin/wasmguest/exports.go b/plugin/wasmguest/exports.go new file mode 100644 index 0000000..df8618a --- /dev/null +++ b/plugin/wasmguest/exports.go @@ -0,0 +1,89 @@ +//go:build wasip1 + +package wasmguest + +import ( + abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" + "unsafe" +) + +// pinned keeps host-visible buffers reachable so the Go GC never frees (or, +// under a future moving GC, relocates) memory the host holds a raw pointer +// into. Keyed by the buffer's linear-memory address. Wasm instances are +// single-threaded per the ABI (one bn_invoke at a time), so no locking. +var pinned = make(map[uint32][]byte) + +// lastResponse pins the most recent bn_invoke response buffer; per the ABI +// it stays valid until the next bn_invoke on this instance. +var lastResponse []byte + +// bn_alloc allocates size bytes of guest memory for the host to write into. +// The region stays valid until the current bn_invoke call returns (request +// buffers) or until released with bn_free / consumed by the shim (host-call +// responses). +// +//go:wasmexport bn_alloc +func bnAlloc(size uint32) uint32 { + if size == 0 { + return 0 + } + buf := make([]byte, size) + ptr := bufPtr(buf) + pinned[ptr] = buf + return ptr +} + +// bn_free releases a buffer previously returned by bn_alloc. Unknown +// pointers are ignored (the shim may have already released the buffer). +// +//go:wasmexport bn_free +func bnFree(ptr uint32) { + delete(pinned, ptr) +} + +// bn_invoke dispatches one hook call. (ptr, len) frames a serialized +// InvokeRequest written by the host into bn_alloc'd memory; the packed +// return value ((ptr << 32) | len) frames a serialized InvokeResponse valid +// until the next bn_invoke. A return of 0 means the guest could not even +// produce an error envelope; the host treats it as INTERNAL and discards +// the instance. +// +//go:wasmexport bn_invoke +func bnInvoke(hookID uint32, ptr uint32, length uint32) uint64 { + var ( + req []byte + known bool + ) + if buf, ok := pinned[ptr]; ok && uint32(len(buf)) >= length { + req = buf[:length:length] + known = true + } + + var resp []byte + switch { + case !known: + // The ABI requires request buffers to come from bn_alloc; anything + // else is a protocol violation, not readable guest memory. + resp = errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE, + "bn_invoke request pointer was not allocated via bn_alloc") + case current == nil: + resp = errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, + "wasmguest.Serve was never called — plugin main package is missing the init boilerplate (see wasmguest doc.go)") + default: + resp = current.invoke(hookID, req) + } + + // Request buffer ownership ends with this call (ABI: valid until + // bn_invoke returns). Release it whether or not the host calls bn_free. + delete(pinned, ptr) + + lastResponse = resp + if len(resp) == 0 { + return 0 + } + return uint64(bufPtr(resp))<<32 | uint64(uint32(len(resp))) +} + +func bufPtr(b []byte) uint32 { + return uint32(uintptr(unsafe.Pointer(&b[0]))) +} diff --git a/plugin/wasmguest/hostcalls.go b/plugin/wasmguest/hostcalls.go new file mode 100644 index 0000000..33b5b90 --- /dev/null +++ b/plugin/wasmguest/hostcalls.go @@ -0,0 +1,109 @@ +//go:build wasip1 + +package wasmguest + +import ( + "fmt" + "runtime" + + abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" + "google.golang.org/protobuf/proto" +) + +// Guest→host capability calls use ONE generic import rather than one import +// per capability family: the method string inside HostCallRequest already +// selects the family (".", including the db.* driver +// messages), so per-family import symbols would add ~40 declarations on +// both sides of the boundary for zero type safety — the payloads are opaque +// protobuf bytes either way. Decision recorded in core/docs/wasm-abi.md. +// +// Shape (mirrors bn_invoke in reverse): the guest passes (ptr, len) framing +// a serialized HostCallRequest; the host returns packed ((ptr << 32) | len) +// framing a HostCallResponse it wrote into guest memory via bn_alloc. +// packed == 0 means the host could not produce an envelope. +// +//go:wasmimport blockninja host_call +//go:noescape +func hostCall(ptr uint32, size uint32) uint64 + +// HostError is a failed capability call's AbiError surfaced as a Go error. +type HostError struct { + Code abiv1.AbiErrorCode + Message string +} + +func (e *HostError) Error() string { + return fmt.Sprintf("host call failed (%s): %s", e.Code, e.Message) +} + +// HostCall performs one guest→host capability call. method is +// "." (e.g. "crypto.encrypt_secret"); payload is the +// serialized family request message. It returns the serialized family +// response payload. The capability-stub implementations of the CoreServices +// interfaces are built on this. +func HostCall(method string, payload []byte) ([]byte, error) { + raw, err := proto.Marshal(&abiv1.HostCallRequest{Method: method, Payload: payload}) + if err != nil { + return nil, fmt.Errorf("wasmguest: marshal HostCallRequest: %w", err) + } + var ptr, size uint32 + if len(raw) > 0 { + ptr = bufPtr(raw) + size = uint32(len(raw)) + } + packed := hostCall(ptr, size) + runtime.KeepAlive(raw) + if packed == 0 { + return nil, &HostError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, + Message: "host returned packed 0 (no envelope) for " + method, + } + } + + respPtr := uint32(packed >> 32) + respLen := uint32(packed) + buf, ok := pinned[respPtr] + if !ok || uint32(len(buf)) < respLen { + // The ABI requires the host to write responses into bn_alloc'd + // memory; anything else is a protocol violation. + return nil, &HostError{ + Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE, + Message: "host response pointer was not allocated via bn_alloc for " + method, + } + } + respBytes := buf[:respLen:respLen] + + resp := &abiv1.HostCallResponse{} + unmarshalErr := proto.Unmarshal(respBytes, resp) // copies; safe to release the buffer + delete(pinned, respPtr) // host allocated via bn_alloc; the shim owns the release + if unmarshalErr != nil { + return nil, fmt.Errorf("wasmguest: decode HostCallResponse for %s: %w", method, unmarshalErr) + } + if e := resp.GetError(); e != nil { + return nil, &HostError{Code: e.GetCode(), Message: e.GetMessage()} + } + return resp.GetPayload(), nil +} + +// CallHost marshals req, performs the capability call, and unmarshals the +// response into resp — the typed convenience wrapper capability stubs use. +func CallHost(method string, req, resp proto.Message) error { + var payload []byte + if req != nil { + b, err := proto.Marshal(req) + if err != nil { + return fmt.Errorf("wasmguest: marshal %s request: %w", method, err) + } + payload = b + } + out, err := HostCall(method, payload) + if err != nil { + return err + } + if resp != nil { + if err := proto.Unmarshal(out, resp); err != nil { + return fmt.Errorf("wasmguest: decode %s response: %w", method, err) + } + } + return nil +} diff --git a/plugin/wasmguest/registries.go b/plugin/wasmguest/registries.go new file mode 100644 index 0000000..a64d52b --- /dev/null +++ b/plugin/wasmguest/registries.go @@ -0,0 +1,114 @@ +package wasmguest + +import ( + "io/fs" + + "git.dev.alexdunmow.com/block/core/blocks" + "git.dev.alexdunmow.com/block/core/templates" +) + +// captureBlockRegistry implements blocks.BlockRegistry by recording every +// registration instead of wiring it into a live host. It backs both DESCRIBE +// (metadata enumeration) and runtime dispatch (BlockFunc lookup). +// +// It is always used behind plugin.NewPluginBlockRegistry, so keys arrive +// already plugin-prefixed and overrides arrive with their source set — +// identical to what a plugin sees in the .so world. +type captureBlockRegistry struct { + metas []blocks.BlockMeta + funcs map[string]blocks.BlockFunc // block key → fn + overrides []blockOverride +} + +type blockOverride struct { + templateKey string + blockKey string + source string + fn blocks.BlockFunc +} + +func newCaptureBlockRegistry() *captureBlockRegistry { + return &captureBlockRegistry{funcs: make(map[string]blocks.BlockFunc)} +} + +func (r *captureBlockRegistry) Register(meta blocks.BlockMeta, fn blocks.BlockFunc) { + r.metas = append(r.metas, meta) + r.funcs[meta.Key] = fn +} + +func (r *captureBlockRegistry) RegisterTemplateOverride(templateKey, blockKey string, fn blocks.BlockFunc) { + r.overrides = append(r.overrides, blockOverride{templateKey: templateKey, blockKey: blockKey, fn: fn}) +} + +func (r *captureBlockRegistry) RegisterTemplateOverrideWithSource(templateKey, blockKey, source string, fn blocks.BlockFunc) { + r.overrides = append(r.overrides, blockOverride{templateKey: templateKey, blockKey: blockKey, source: source, fn: fn}) +} + +// LoadSchemasFromFS is a no-op in the wasm guest: block schemas ship in the +// .bnp artifact's schemas/ directory and are loaded host-side. +func (r *captureBlockRegistry) LoadSchemasFromFS(fs.FS) error { return nil } + +func (r *captureBlockRegistry) LoadSchemasFromFSWithPrefix(fs.FS, string) error { return nil } + +// lookup resolves a block function for dispatch: a template-scoped override +// wins over the base registration, mirroring the host registry's behavior. +func (r *captureBlockRegistry) lookup(blockKey, templateKey string) (blocks.BlockFunc, bool) { + if templateKey != "" { + for _, o := range r.overrides { + if o.templateKey == templateKey && o.blockKey == blockKey { + return o.fn, true + } + } + } + fn, ok := r.funcs[blockKey] + return fn, ok +} + +// captureTemplateRegistry implements templates.TemplateRegistry by recording +// registrations for DESCRIBE and runtime dispatch. +type captureTemplateRegistry struct { + templateKeys []string // insertion order of Register calls + funcs map[string]templates.TemplateFunc + systemTemplates []templates.SystemTemplateMeta + pageTemplates []capturedPageTemplate + emailWrappers []capturedEmailWrapper +} + +type capturedPageTemplate struct { + systemKey string + meta templates.PageTemplateMeta +} + +type capturedEmailWrapper struct { + systemKey string + fn templates.EmailWrapperFunc +} + +func newCaptureTemplateRegistry() *captureTemplateRegistry { + return &captureTemplateRegistry{funcs: make(map[string]templates.TemplateFunc)} +} + +func (r *captureTemplateRegistry) Register(key string, fn templates.TemplateFunc) error { + r.templateKeys = append(r.templateKeys, key) + r.funcs[key] = fn + return nil +} + +func (r *captureTemplateRegistry) RegisterSystemTemplate(meta templates.SystemTemplateMeta) { + r.systemTemplates = append(r.systemTemplates, meta) +} + +func (r *captureTemplateRegistry) RegisterPageTemplate(systemKey string, meta templates.PageTemplateMeta, fn templates.TemplateFunc) error { + r.pageTemplates = append(r.pageTemplates, capturedPageTemplate{systemKey: systemKey, meta: meta}) + r.funcs[meta.Key] = fn + return nil +} + +func (r *captureTemplateRegistry) RegisterEmailWrapper(systemKey string, fn templates.EmailWrapperFunc) { + r.emailWrappers = append(r.emailWrappers, capturedEmailWrapper{systemKey: systemKey, fn: fn}) +} + +func (r *captureTemplateRegistry) lookup(key string) (templates.TemplateFunc, bool) { + fn, ok := r.funcs[key] + return fn, ok +}