core/plugin/wasmguest/context.go
Alex Dunmow c35199f0bc 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>
2026-07-03 13:53:24 +08:00

226 lines
6.9 KiB
Go

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
}