feat(wasmguest): guest runtime shim for the wasm plugin ABI (WO-WZ-002)

The wasip1 half of the ABI: bn_alloc/bn_invoke/bn_free exports with a
live-pin map so the GC never frees host-visible buffers, the generic
`blockninja.host_call` import (single import decided over per-family
symbols; recorded in wasm-abi.md), and a dispatch table adapting an
unmodified plugin.PluginRegistration to all nine v1 hooks. Panics inside
plugin hooks come back as ABI_ERROR_CODE_INTERNAL — the instance stays
callable; traps stay reserved for runtime corruption.

DESCRIBE builds the PluginManifest from the registration's static funcs
plus a capture-only Register pass (block metas via the same
PluginBlockRegistry prefixing the .so loader applies, template/system/
page-template/email-wrapper keys), probes JobHandlers/ServiceHandlers/
Load with capture-only services for job types, RBAC roles, core-service
bindings, and RAG fetcher types. RenderContext values are rehydrated
through the exact core/blocks context keys, so existing block code
reading from ctx works unchanged.

Plugins build in REACTOR mode (go build -buildmode=c-shared): init()
calls wasmguest.Serve (non-blocking), main is never called, and the host
runs _initialize before any bn_invoke. Command mode deadlocks or exits
(verified against wazero v1.12.0) — documented prominently in
wasm-abi.md, which also now reconciles the import module namespace to
`blockninja` and requires bn_alloc'd buffers on both directions.

Dispatch/describe/context logic is buildable on every GOOS; only
exports.go and hostcalls.go carry the wasip1 tag. dispatch_test.go
covers describe, hook routing, envelope mismatch, decode failures,
template-override resolution, panic recovery, and lifecycle hooks
natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-03 13:53:24 +08:00
parent 704b046727
commit c35199f0bc
9 changed files with 1774 additions and 4 deletions

View File

@ -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 — `"<family>.<snake_method>"`, 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

225
plugin/wasmguest/context.go Normal file
View File

@ -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
}

View File

@ -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 }

View File

@ -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
}

View File

@ -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("<p>hello %s on %s (tpl %s)</p>", name, pageSlug, blocks.GetTemplateKey(ctx))
})
br.RegisterTemplateOverride("landing", "hello", func(ctx context.Context, content map[string]any) string {
return "<p>override hello</p>"
})
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("<html>" + title + "</html>")
}); 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("<page/>")
})
},
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 := "<p>hello world on home (tpl fixture-tpl)</p>"
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() != "<p>override hello</p>" {
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()) != "<html>My Page</html>" {
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())
}
}

46
plugin/wasmguest/doc.go Normal file
View File

@ -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

View File

@ -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])))
}

View File

@ -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 ("<family>.<snake_method>", 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
// "<family>.<snake_method>" (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
}

View File

@ -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
}