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:
parent
704b046727
commit
c35199f0bc
@ -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
225
plugin/wasmguest/context.go
Normal 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
|
||||
}
|
||||
327
plugin/wasmguest/describe.go
Normal file
327
plugin/wasmguest/describe.go
Normal 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 }
|
||||
438
plugin/wasmguest/dispatch.go
Normal file
438
plugin/wasmguest/dispatch.go
Normal 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
|
||||
}
|
||||
371
plugin/wasmguest/dispatch_test.go
Normal file
371
plugin/wasmguest/dispatch_test.go
Normal 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
46
plugin/wasmguest/doc.go
Normal 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
|
||||
89
plugin/wasmguest/exports.go
Normal file
89
plugin/wasmguest/exports.go
Normal 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])))
|
||||
}
|
||||
109
plugin/wasmguest/hostcalls.go
Normal file
109
plugin/wasmguest/hostcalls.go
Normal 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
|
||||
}
|
||||
114
plugin/wasmguest/registries.go
Normal file
114
plugin/wasmguest/registries.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user