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>
226 lines
6.9 KiB
Go
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
|
|
}
|