diff --git a/cli/gonext/cmd/plugin/init.go b/cli/gonext/cmd/plugin/init.go new file mode 100644 index 00000000..11d08981 --- /dev/null +++ b/cli/gonext/cmd/plugin/init.go @@ -0,0 +1,199 @@ +package plugin + +import ( + "embed" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// runInit implements `gonext plugin init [--template=go] `. +// +// It scaffolds a fresh plugin project from one of the embedded +// templates. Today only --template=go is supported (the +// TinyGo-targeted SDK template); future templates (rust, +// assemblyscript) land here as additional subdirs. +// +// The scaffold writes: +// +// /main.go the entry point with one action + +// one filter stub and a manifest +// builder +// /manifest.json the canonical manifest (matches +// the SDK's builder output) +// /go.mod the Go module declaration +// /Makefile build / bundle / test targets +// /.gitignore exclude plugin.wasm, *.gnplugin +// +// Existing files at the target are NOT overwritten unless --force is +// passed. The conservative default surfaces a clear error so a user +// doesn't lose work by accident. + +func runInit(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("gonext plugin init", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { + fmt.Fprintln(stderr, initUsage) + } + + template := fs.String("template", "go", "template name (go is the only choice today)") + pluginName := fs.String("name", "", "plugin slug for the manifest (default: project dir basename)") + force := fs.Bool("force", false, "overwrite existing files at the target") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return ExitOK + } + return ExitUsage + } + + rest := fs.Args() + if len(rest) == 0 { + fmt.Fprintln(stderr, "gonext plugin init: missing project directory") + fmt.Fprintln(stderr, initUsage) + return ExitUsage + } + if len(rest) > 1 { + fmt.Fprintf(stderr, "gonext plugin init: unexpected extra arguments: %v\n", rest[1:]) + fmt.Fprintln(stderr, initUsage) + return ExitUsage + } + + projectDir, err := filepath.Abs(rest[0]) + if err != nil { + fmt.Fprintf(stderr, "gonext plugin init: resolving project dir: %s\n", err) + return ExitFail + } + slug := *pluginName + if slug == "" { + slug = sanitizeSlug(filepath.Base(projectDir)) + } + + if *template != "go" { + fmt.Fprintf(stderr, "gonext plugin init: unknown template %q (only \"go\" is supported)\n", *template) + return ExitUsage + } + + if err := os.MkdirAll(projectDir, 0o755); err != nil { + fmt.Fprintf(stderr, "gonext plugin init: creating project dir: %s\n", err) + return ExitFail + } + + if err := writeTemplateGo(projectDir, slug, *force); err != nil { + fmt.Fprintf(stderr, "gonext plugin init: %s\n", err) + return ExitFail + } + + fmt.Fprintf(stdout, "Initialized GoNext Go plugin in %s\n", projectDir) + fmt.Fprintln(stdout, "Next steps:") + fmt.Fprintln(stdout, " cd "+projectDir) + fmt.Fprintln(stdout, " go mod tidy") + fmt.Fprintln(stdout, " make # builds plugin.wasm via TinyGo") + fmt.Fprintln(stdout, " make bundle # packs the .gnplugin ZIP") + return ExitOK +} + +const initUsage = `gonext plugin init — scaffold a new plugin project + +Usage: + gonext plugin init [flags] + +Flags: + --template= template to use (default: go) + --name= plugin slug to embed in manifest.json (default: basename + of ) + --force overwrite existing files at the target + +Templates: + go TinyGo-targeted Go plugin using packages/go/sdk + +Example: + gonext plugin init --template=go ./my-plugin` + +// templatesFS embeds the templates directory tree. Each file is +// rendered by trivial token substitution — {{PLUGIN_NAME}} becomes +// the manifest slug. We deliberately don't pull in text/template +// because the rendering is straight-line. +// +//go:embed templates/go/* +var templatesFS embed.FS + +// writeTemplateGo renders the Go template into dir. Returns an error +// if a target file already exists and force is false. +func writeTemplateGo(dir, slug string, force bool) error { + root := "templates/go" + return fs.WalkDir(templatesFS, root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return fmt.Errorf("relativise template path %q: %w", path, err) + } + // Rename foo.tmpl -> foo so a user editing the template + // in-place doesn't accidentally trip Go's build on the + // .tmpl extension. The convention applies to every file + // that benefits from it; today only main.go.tmpl uses it. + target := filepath.Join(dir, strings.TrimSuffix(rel, ".tmpl")) + // gitignore.tmpl becomes .gitignore — the leading-dot + // rename is special-cased because the embedded + // filesystem can't carry dotfiles cleanly. + if filepath.Base(target) == "gitignore" { + target = filepath.Join(filepath.Dir(target), ".gitignore") + } + + if !force { + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("file already exists: %s (use --force to overwrite)", target) + } + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("create dir for %q: %w", target, err) + } + data, err := templatesFS.ReadFile(path) + if err != nil { + return fmt.Errorf("read template %q: %w", path, err) + } + rendered := strings.ReplaceAll(string(data), "{{PLUGIN_NAME}}", slug) + rendered = strings.ReplaceAll(rendered, "{{PLUGIN_NAME_LITERAL}}", slug) + if err := os.WriteFile(target, []byte(rendered), 0o644); err != nil { + return fmt.Errorf("write %q: %w", target, err) + } + return nil + }) +} + +// sanitizeSlug converts a directory basename into a plugin-manifest- +// safe slug: lowercase ASCII, hyphens for non-alphanumerics, no +// leading/trailing hyphens, falling back to "my-plugin" if nothing +// remains. +// +// The host's manifest schema accepts /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; +// the sanitiser produces output that always satisfies that regex. +func sanitizeSlug(in string) string { + var b strings.Builder + b.Grow(len(in)) + for _, r := range strings.ToLower(in) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-', r == '_', r == ' ', r == '.': + b.WriteByte('-') + } + } + out := strings.Trim(b.String(), "-") + for strings.Contains(out, "--") { + out = strings.ReplaceAll(out, "--", "-") + } + if out == "" { + return "my-plugin" + } + return out +} diff --git a/cli/gonext/cmd/plugin/init_test.go b/cli/gonext/cmd/plugin/init_test.go new file mode 100644 index 00000000..ed5b5335 --- /dev/null +++ b/cli/gonext/cmd/plugin/init_test.go @@ -0,0 +1,176 @@ +package plugin + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// init_test.go validates the `gonext plugin init` scaffolder: argument +// parsing, template rendering, the slug-substitution pass, and the +// "don't clobber existing files" safety. The render itself is straight +// string-replacement so the tests focus on outcomes (files written, +// substitutions applied) rather than internal mechanics. + +func TestRunInitWritesExpectedFiles(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "my-plugin") + + var stdout, stderr bytes.Buffer + code := runInit([]string{"--template=go", "--name=acme-hello", target}, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("init failed: code=%d stderr=%s", code, stderr.String()) + } + + wantFiles := []string{"main.go", "manifest.json", "go.mod", "Makefile", ".gitignore"} + for _, f := range wantFiles { + path := filepath.Join(target, f) + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file %s to exist: %v", f, err) + } + } +} + +func TestRunInitSlugSubstitution(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "my-plugin") + + var stdout, stderr bytes.Buffer + code := runInit([]string{"--name=acme-hello", target}, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("init failed: code=%d stderr=%s", code, stderr.String()) + } + + mainBytes, err := os.ReadFile(filepath.Join(target, "main.go")) + if err != nil { + t.Fatalf("read main.go: %v", err) + } + if !strings.Contains(string(mainBytes), `"acme-hello"`) { + t.Errorf("main.go missing slug; first 200 chars: %s", string(mainBytes)[:200]) + } + if strings.Contains(string(mainBytes), "{{PLUGIN_NAME}}") { + t.Errorf("main.go still contains unreplaced template token") + } + + manifestBytes, err := os.ReadFile(filepath.Join(target, "manifest.json")) + if err != nil { + t.Fatalf("read manifest.json: %v", err) + } + if !strings.Contains(string(manifestBytes), `"name": "acme-hello"`) { + t.Errorf("manifest.json missing slug substitution: %s", manifestBytes) + } +} + +func TestRunInitDefaultsToBasename(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "MyPlugin") + + var stdout, stderr bytes.Buffer + code := runInit([]string{target}, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("init failed: code=%d stderr=%s", code, stderr.String()) + } + + manifestBytes, err := os.ReadFile(filepath.Join(target, "manifest.json")) + if err != nil { + t.Fatalf("read manifest.json: %v", err) + } + // MyPlugin -> myplugin (lowercased; no separators to dash). + if !strings.Contains(string(manifestBytes), `"name": "myplugin"`) { + t.Errorf("manifest.json: expected sanitized slug 'myplugin', got: %s", manifestBytes) + } +} + +func TestRunInitRefusesToClobber(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "my-plugin") + + // First run succeeds. + var stdout, stderr bytes.Buffer + if code := runInit([]string{target}, &stdout, &stderr); code != ExitOK { + t.Fatalf("first init failed: %d %s", code, stderr.String()) + } + + // Second run without --force fails. + stdout.Reset() + stderr.Reset() + code := runInit([]string{target}, &stdout, &stderr) + if code != ExitFail { + t.Errorf("expected ExitFail on second init, got %d", code) + } + if !strings.Contains(stderr.String(), "already exists") { + t.Errorf("expected 'already exists' in stderr, got: %s", stderr.String()) + } + + // Third run with --force succeeds. + stdout.Reset() + stderr.Reset() + code = runInit([]string{"--force", target}, &stdout, &stderr) + if code != ExitOK { + t.Errorf("expected ExitOK with --force, got %d: %s", code, stderr.String()) + } +} + +func TestRunInitUnknownTemplate(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "my-plugin") + + var stdout, stderr bytes.Buffer + code := runInit([]string{"--template=rust", target}, &stdout, &stderr) + if code != ExitUsage { + t.Errorf("expected ExitUsage, got %d", code) + } + if !strings.Contains(stderr.String(), "unknown template") { + t.Errorf("expected 'unknown template' in stderr: %s", stderr.String()) + } +} + +func TestRunInitMissingArg(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runInit([]string{}, &stdout, &stderr) + if code != ExitUsage { + t.Errorf("expected ExitUsage, got %d", code) + } +} + +func TestSanitizeSlug(t *testing.T) { + cases := []struct { + in, want string + }{ + {"hello", "hello"}, + {"Hello", "hello"}, + {"my-plugin", "my-plugin"}, + {"my_plugin", "my-plugin"}, + {"My Plugin", "my-plugin"}, + {"hello.world", "hello-world"}, + {"---trim---", "trim"}, + {"a--b", "a-b"}, + {"!@#$%", "my-plugin"}, + {"", "my-plugin"}, + } + for _, tc := range cases { + got := sanitizeSlug(tc.in) + if got != tc.want { + t.Errorf("sanitizeSlug(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestPluginInitCommandWired(t *testing.T) { + // Verify that `gonext plugin init` dispatches through the Run + // entry point — guards against accidental removal from the + // switch in plugin.go. + dir := t.TempDir() + target := filepath.Join(dir, "wired-test") + + var stdout, stderr bytes.Buffer + code := Run([]string{"init", target}, &stdout, &stderr) + if code != ExitOK { + t.Fatalf("Run init failed: %d %s", code, stderr.String()) + } + if _, err := os.Stat(filepath.Join(target, "main.go")); err != nil { + t.Errorf("Run init did not produce main.go: %v", err) + } +} diff --git a/cli/gonext/cmd/plugin/plugin.go b/cli/gonext/cmd/plugin/plugin.go index 23806602..5f3755d3 100644 --- a/cli/gonext/cmd/plugin/plugin.go +++ b/cli/gonext/cmd/plugin/plugin.go @@ -44,6 +44,8 @@ func Run(args []string, stdout, stderr io.Writer) int { return runSign(args[1:], stdout, stderr) case "diff": return runDiff(args[1:], stdout, stderr) + case "init": + return runInit(args[1:], stdout, stderr) default: fmt.Fprintf(stderr, "gonext plugin: unknown subcommand %q\n\n%s\n", args[0], usage) return ExitUsage @@ -60,6 +62,7 @@ Usage: gonext plugin [args] Subcommands: + init Scaffold a new plugin project (Go template via TinyGo). test Run the plugin contract checks against a bundle. dev Run the plugin author dev loop (auto-detect, build, upload, watch). replay Re-run a recorded plugin trap against the currently-loaded bytes. diff --git a/cli/gonext/cmd/plugin/templates/go/Makefile b/cli/gonext/cmd/plugin/templates/go/Makefile new file mode 100644 index 00000000..1d86e93c --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/go/Makefile @@ -0,0 +1,35 @@ +# Makefile for the {{PLUGIN_NAME}} plugin. +# +# Default target produces plugin.wasm via TinyGo. The bundle target +# produces a .gnplugin ZIP suitable for `gonext plugin install`. +# +# Requirements: +# tinygo >= 0.31.0 (https://tinygo.org/getting-started/install) +# Go >= 1.25.0 +# zip (for the bundle target) + +WASM := plugin.wasm +BUNDLE := {{PLUGIN_NAME}}.gnplugin + +.PHONY: all clean bundle test + +all: $(WASM) + +$(WASM): main.go go.mod + @command -v tinygo >/dev/null 2>&1 || { \ + echo "tinygo not on PATH; install from https://tinygo.org/" >&2; exit 1; } + tinygo build -target=wasi -no-debug -o $(WASM) . + @echo "[build] $(WASM) ($$(wc -c <$(WASM) | tr -d ' ') bytes)" + +bundle: $(WASM) + zip -j $(BUNDLE) manifest.json $(WASM) + @echo "[bundle] $(BUNDLE)" + +# test runs the stock-Go tests for handler logic. The SDK stubs out +# host calls under the stock toolchain so unit tests focus on the +# Go-level behaviour. +test: + go test ./... + +clean: + rm -f $(WASM) $(BUNDLE) diff --git a/cli/gonext/cmd/plugin/templates/go/gitignore.tmpl b/cli/gonext/cmd/plugin/templates/go/gitignore.tmpl new file mode 100644 index 00000000..c4be05bf --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/go/gitignore.tmpl @@ -0,0 +1,6 @@ +# Build outputs. +/plugin.wasm +/*.gnplugin + +# Go module cache (created by `go mod download` on some toolchains). +/vendor/ diff --git a/cli/gonext/cmd/plugin/templates/go/go.mod.tmpl b/cli/gonext/cmd/plugin/templates/go/go.mod.tmpl new file mode 100644 index 00000000..6396b2f6 --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/go/go.mod.tmpl @@ -0,0 +1,5 @@ +module {{PLUGIN_NAME}} + +go 1.25.0 + +require github.com/Singleton-Solution/GoNext/packages/go/sdk v0.0.0 diff --git a/cli/gonext/cmd/plugin/templates/go/main.go.tmpl b/cli/gonext/cmd/plugin/templates/go/main.go.tmpl new file mode 100644 index 00000000..c9459d80 --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/go/main.go.tmpl @@ -0,0 +1,84 @@ +// Package main is the entry point for the {{PLUGIN_NAME}} plugin. +// +// Build with TinyGo: +// +// tinygo build -target=wasi -no-debug -o plugin.wasm . +// +// The output is a wasm32-wasi module the GoNext lifecycle Manager +// accepts. Pack with the bundled Makefile: +// +// make bundle # produces {{PLUGIN_NAME}}.gnplugin +package main + +import ( + "encoding/json" + "fmt" + + "github.com/Singleton-Solution/GoNext/packages/go/sdk" +) + +// main is the TinyGo entry point. Register every hook handler here, +// then call sdk.PluginInit with the manifest the plugin declares. +// Dispatch is via the SDK's gn_handle_hook export — main only +// configures the registry. +func main() { + sdk.RegisterAction("posts.publish", onPostsPublish) + sdk.RegisterFilter("the_content", onTheContent) + + sdk.PluginInit(sdk.NewManifest("{{PLUGIN_NAME}}", "0.1.0"). + WithCapability("kv.write"). + WithCapability("audit.emit"). + WithCapability("hooks.subscribe"). + WithAction("posts.publish"). + WithFilter("the_content"). + WithHostRequirement(">=0.1.0"). + MustBuild()) +} + +// onPostsPublish is the action handler stub. The host bus invokes +// this hook when a post moves into the published state; args[0] is +// the JSON-decoded post object. +// +// Returning a non-nil error surfaces as ResultStatusError to the +// host bus. The rest of the chain still runs; the operator sees the +// failure in audit + slog. +func onPostsPublish(args []any) error { + if len(args) == 0 { + return fmt.Errorf("posts.publish: no post argument") + } + post, ok := args[0].(map[string]any) + if !ok { + return fmt.Errorf("posts.publish: arg[0] is %T, want map", args[0]) + } + id, _ := post["id"].(string) + if id == "" { + return fmt.Errorf("posts.publish: missing or empty id") + } + + // Stash the latest published post id under the plugin's KV + // namespace. The host prefixes keys with the plugin slug; we + // see our own bare keys. + if err := sdk.Host.KV.Set("last-published-id", []byte(id)); err != nil { + sdk.Host.Log.Warn("kv.set failed: " + err.Error()) + } + + // Emit one audit row. The host tags the row with our slug. + if err := sdk.Host.Audit.Emit("post.indexed", map[string]any{ + "post_id": id, + }); err != nil { + sdk.Host.Log.Warn("audit.emit failed: " + err.Error()) + } + return nil +} + +// onTheContent is the filter handler stub. The host bus passes the +// post body as a JSON-encoded string in value; the return must be a +// JSON-encoded string of the transformed body. +func onTheContent(value json.RawMessage, _ []any) (json.RawMessage, error) { + var body string + if err := json.Unmarshal(value, &body); err != nil { + return nil, fmt.Errorf("the_content: unmarshal value: %w", err) + } + body += "\n" + return json.Marshal(body) +} diff --git a/cli/gonext/cmd/plugin/templates/go/manifest.json b/cli/gonext/cmd/plugin/templates/go/manifest.json new file mode 100644 index 00000000..a135459a --- /dev/null +++ b/cli/gonext/cmd/plugin/templates/go/manifest.json @@ -0,0 +1,22 @@ +{ + "apiVersion": "gonext.io/v1", + "name": "{{PLUGIN_NAME}}", + "version": "0.1.0", + "entry": "plugin.wasm", + "capabilities": [ + "kv.write", + "audit.emit", + "hooks.subscribe" + ], + "hooks": { + "actions": [ + "posts.publish" + ], + "filters": [ + "the_content" + ] + }, + "requires": { + "host": ">=0.1.0" + } +} diff --git a/examples/plugins/sdk-go-hello/Makefile b/examples/plugins/sdk-go-hello/Makefile new file mode 100644 index 00000000..5f47a71f --- /dev/null +++ b/examples/plugins/sdk-go-hello/Makefile @@ -0,0 +1,48 @@ +# Makefile for the sdk-go-hello example plugin. +# +# The default target produces plugin.wasm via TinyGo. The bundle +# target produces a .gnplugin ZIP suitable for `gonext plugin +# install`. +# +# Requirements: +# - tinygo >= 0.31.0 (https://tinygo.org/getting-started/install) +# - Go >= 1.25.0 (for the host-side stock builds) +# - zip (for the bundle target) +# +# The build is deliberately straight-line: TinyGo gets the wasi +# target, -no-debug strips DWARF, and the output is the same +# plugin.wasm the manifest.json entry references. + +WASM := plugin.wasm +BUNDLE := sdk-go-hello.gnplugin + +.PHONY: all clean bundle test check + +all: $(WASM) + +$(WASM): main.go go.mod + @command -v tinygo >/dev/null 2>&1 || { \ + echo "tinygo not on PATH; install from https://tinygo.org/" >&2; exit 1; } + tinygo build -target=wasi -no-debug -o $(WASM) . + @echo "[build] $(WASM) ($$(wc -c <$(WASM) | tr -d ' ') bytes)" + +# bundle packs manifest + wasm into the .gnplugin ZIP the lifecycle +# Manager accepts. The -j flag flattens paths so the ZIP doesn't +# carry the project directory prefix. +bundle: $(WASM) + zip -j $(BUNDLE) manifest.json $(WASM) + @echo "[bundle] $(BUNDLE)" + +# test runs the stock-Go tests for the plugin's handler logic. The +# SDK's host-call layer is stubbed under the stock toolchain so +# tests focus on the Go-level handler behaviour. +test: + go test ./... + +# check is the CI gate: compile under both toolchains so a TinyGo- +# only regression doesn't ship. +check: test $(WASM) + @echo "[check] OK" + +clean: + rm -f $(WASM) $(BUNDLE) diff --git a/examples/plugins/sdk-go-hello/README.md b/examples/plugins/sdk-go-hello/README.md new file mode 100644 index 00000000..9b938d0c --- /dev/null +++ b/examples/plugins/sdk-go-hello/README.md @@ -0,0 +1,37 @@ +# sdk-go-hello + +Worked example for the Go plugin SDK at `packages/go/sdk`. Compiles +to a WASM module via TinyGo and exercises: + +- An action handler for `posts.publish` +- A filter handler for `the_content` +- One `gn_kv_set` call +- One `gn_audit_emit` call + +## Build + +```bash +make # produces plugin.wasm +make bundle # produces sdk-go-hello.gnplugin +``` + +Requires `tinygo >= 0.31.0` on PATH. + +## Install + +Once the host is running: + +```bash +gonext plugin install ./sdk-go-hello.gnplugin +gonext plugin activate gonext-sdk-go-hello +``` + +## Test + +```bash +make test # stock-Go tests against handler logic +``` + +The SDK stubs out the host-call layer under the stock toolchain so +the test target validates handler semantics without TinyGo. To +verify the full WASM build, `make check` runs both. diff --git a/examples/plugins/sdk-go-hello/build.sh b/examples/plugins/sdk-go-hello/build.sh new file mode 100755 index 00000000..d7a64322 --- /dev/null +++ b/examples/plugins/sdk-go-hello/build.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# build.sh — compile the sdk-go-hello example to plugin.wasm via TinyGo. +# +# This mirrors examples/plugins/seo/build.sh but builds against the +# packages/go/sdk wrapper instead of the raw ABI. The output is a +# wasm32-wasi module the GoNext lifecycle Manager accepts. +# +# Usage: +# +# ./build.sh +# +# Prerequisites: +# +# - TinyGo >= 0.31.0 +# - Go >= 1.25.0 +# +# Output: +# +# ./plugin.wasm +# +# After build, the script verifies the binary exports the required +# ABI symbols (gn_handle_hook, gn_alloc, gn_free) and is non-empty. + +set -euo pipefail + +cd "$(dirname "$0")" + +OUTPUT=plugin.wasm + +echo "[build] compiling $OUTPUT with TinyGo..." +if ! command -v tinygo >/dev/null 2>&1; then + echo "[build] tinygo not on PATH; install from https://tinygo.org/" >&2 + exit 1 +fi + +tinygo build \ + -target=wasi \ + -no-debug \ + -o "$OUTPUT" \ + . + +if [ ! -s "$OUTPUT" ]; then + echo "[build] FAIL: $OUTPUT was not produced or is empty" >&2 + exit 1 +fi + +echo "[build] checking required exports..." +required_exports=(gn_handle_hook gn_alloc gn_free) +missing=0 +for sym in "${required_exports[@]}"; do + if ! grep -aq "$sym" "$OUTPUT"; then + echo "[build] FAIL: missing export $sym" >&2 + missing=1 + fi +done +if [ "$missing" -ne 0 ]; then + exit 1 +fi + +size_bytes=$(wc -c <"$OUTPUT" | tr -d ' ') +echo "[build] OK: $OUTPUT ($size_bytes bytes)" +echo "[build] To pack as a .gnplugin bundle:" +echo "[build] zip -j sdk-go-hello.gnplugin manifest.json plugin.wasm" diff --git a/examples/plugins/sdk-go-hello/go.mod b/examples/plugins/sdk-go-hello/go.mod new file mode 100644 index 00000000..4ad72eed --- /dev/null +++ b/examples/plugins/sdk-go-hello/go.mod @@ -0,0 +1,13 @@ +// SDK-based hello-world plugin module. +// +// Imports the SDK at packages/go/sdk via a relative replace +// directive so a tinygo build pulls the SDK from the same repo. Real +// plugins ship a module that depends on a tagged SDK release. + +module github.com/Singleton-Solution/GoNext/examples/plugins/sdk-go-hello + +go 1.25.0 + +require github.com/Singleton-Solution/GoNext/packages/go/sdk v0.0.0 + +replace github.com/Singleton-Solution/GoNext/packages/go/sdk => ../../../packages/go/sdk diff --git a/examples/plugins/sdk-go-hello/main.go b/examples/plugins/sdk-go-hello/main.go new file mode 100644 index 00000000..75d3d9a0 --- /dev/null +++ b/examples/plugins/sdk-go-hello/main.go @@ -0,0 +1,127 @@ +// Package main is the SDK-based hello-world plugin for GoNext. +// +// Unlike examples/plugins/seo (which is built directly against the +// raw ABI), this example uses the public SDK at +// packages/go/sdk — so it doubles as a worked example of every +// surface a plugin author touches. +// +// Build: +// +// tinygo build -target=wasi -no-debug -o plugin.wasm . +// +// or via the Makefile: +// +// make plugin.wasm +// +// The output is a wasm32-wasi module the GoNext lifecycle Manager +// accepts. Ship plugin.wasm + manifest.json as a .gnplugin ZIP: +// +// zip -j hello.gnplugin manifest.json plugin.wasm +// +// # What this plugin does +// +// - Registers an action handler for posts.publish — stores the +// published post's id under the plugin's KV namespace and emits +// a `post.indexed` audit row. +// +// - Registers a filter handler for the_content — appends a +// hello-world marker to the post body. +// +// - Declares the matching manifest (capabilities + hooks + +// storage budget) programmatically via the SDK's builder. The +// actual manifest.json in the bundle is the canonical source of +// truth that the lifecycle Manager validates; the SDK's +// PluginInit call is the runtime self-description. +package main + +import ( + "encoding/json" + "fmt" + + "github.com/Singleton-Solution/GoNext/packages/go/sdk" +) + +// main is the TinyGo entry point. We register every hook handler +// here, then call sdk.PluginInit with the manifest the plugin +// declares. Dispatch is via the SDK's gn_handle_hook export, not +// via main — main only configures the registry. +func main() { + sdk.RegisterAction("posts.publish", onPostsPublish) + sdk.RegisterFilter("the_content", onTheContent) + + // Self-describe via the manifest builder. The host validates + // against the bundled manifest.json — the SDK's record exists + // for dev tooling that wants to introspect the running plugin + // without unpacking the bundle. + sdk.PluginInit(sdk.NewManifest("gonext-sdk-go-hello", "0.1.0"). + WithCapability("kv.write"). + WithCapability("audit.emit"). + WithCapability("hooks.subscribe"). + WithAction("posts.publish"). + WithFilter("the_content"). + WithHostRequirement(">=0.1.0"). + MustBuild()) +} + +// onPostsPublish is the action handler for the posts.publish hook. +// +// The host bus calls posts.publish with one positional argument: the +// JSON-encoded post object. We extract the id, persist it as the +// "last-published-id" KV value (so an admin dashboard can show the +// most recent indexed post), and emit an audit row tagged with the +// post id. +// +// Returning a non-nil error from this handler would surface as +// ResultStatusError to the host bus — the rest of the chain still +// runs, but the operator sees the failure in audit + slog. +func onPostsPublish(args []any) error { + if len(args) == 0 { + return fmt.Errorf("posts.publish: no post argument") + } + post, ok := args[0].(map[string]any) + if !ok { + return fmt.Errorf("posts.publish: arg[0] is %T, want map", args[0]) + } + id, _ := post["id"].(string) + if id == "" { + return fmt.Errorf("posts.publish: missing or empty id") + } + + // Persist the id under the plugin's KV namespace. The host + // adds the per-plugin prefix; we see our own bare keys. + if err := sdk.Host.KV.Set("last-published-id", []byte(id)); err != nil { + // KV failures aren't fatal to the action — the audit + // emission below is the load-bearing side effect. + sdk.Host.Log.Warn("kv.set failed: " + err.Error()) + } + + // Emit one audit row. Metadata becomes the audit_log row's + // metadata JSON column; the host tags it with our slug. + if err := sdk.Host.Audit.Emit("post.indexed", map[string]any{ + "post_id": id, + "plugin": "gonext-sdk-go-hello", + }); err != nil { + // Audit failures are best-effort host-side; a failure + // here typically means the audit store is down — not the + // plugin's fault. Log and continue. + sdk.Host.Log.Warn("audit.emit failed: " + err.Error()) + } + + return nil +} + +// onTheContent is the filter handler for the_content. It receives +// the post body as a JSON-encoded string (raw bytes) and returns the +// transformed body — same shape. +// +// We append a hello-world marker to demonstrate value-transforming +// filters. A real plugin would do something useful like injecting +// SEO meta, rewriting links, or running a markdown pipeline. +func onTheContent(value json.RawMessage, _ []any) (json.RawMessage, error) { + var body string + if err := json.Unmarshal(value, &body); err != nil { + return nil, fmt.Errorf("the_content: unmarshal value: %w", err) + } + body += "\n" + return json.Marshal(body) +} diff --git a/examples/plugins/sdk-go-hello/main_test.go b/examples/plugins/sdk-go-hello/main_test.go new file mode 100644 index 00000000..5898fe40 --- /dev/null +++ b/examples/plugins/sdk-go-hello/main_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "testing" +) + +// main_test.go validates the handler logic under the stock Go +// toolchain. The SDK stubs out host calls in this build, so we focus +// on parameter handling, error paths, and the filter's +// value-transformation contract. + +func TestOnPostsPublishHappyPath(t *testing.T) { + // The SDK stub for KV.Set / Audit.Emit returns an error + // (statusHostUnavailable). The handler logs but continues — + // we assert it doesn't return an error to the caller for the + // "happy path" arg shape. + post := map[string]any{"id": "post-42", "title": "Hello"} + if err := onPostsPublish([]any{post}); err != nil { + t.Fatalf("onPostsPublish returned error: %v", err) + } +} + +func TestOnPostsPublishMissingArgs(t *testing.T) { + if err := onPostsPublish(nil); err == nil { + t.Error("expected error for nil args") + } + if err := onPostsPublish([]any{}); err == nil { + t.Error("expected error for empty args") + } +} + +func TestOnPostsPublishWrongArgType(t *testing.T) { + if err := onPostsPublish([]any{"not a map"}); err == nil { + t.Error("expected error for non-map arg") + } +} + +func TestOnPostsPublishMissingID(t *testing.T) { + post := map[string]any{"title": "Hello"} // no id + if err := onPostsPublish([]any{post}); err == nil { + t.Error("expected error for missing id") + } + post = map[string]any{"id": ""} // empty id + if err := onPostsPublish([]any{post}); err == nil { + t.Error("expected error for empty id") + } +} + +func TestOnTheContentAppendsMarker(t *testing.T) { + value := json.RawMessage(`"original body"`) + out, err := onTheContent(value, nil) + if err != nil { + t.Fatalf("onTheContent error: %v", err) + } + var body string + if err := json.Unmarshal(out, &body); err != nil { + t.Fatalf("unmarshal output: %v", err) + } + want := "original body\n" + if body != want { + t.Errorf("got %q, want %q", body, want) + } +} + +func TestOnTheContentBadValue(t *testing.T) { + // Non-string JSON value — the handler expects the post body + // as a JSON-encoded string and should reject anything else. + value := json.RawMessage(`42`) + if _, err := onTheContent(value, nil); err == nil { + t.Error("expected error for non-string value") + } +} diff --git a/examples/plugins/sdk-go-hello/manifest.json b/examples/plugins/sdk-go-hello/manifest.json new file mode 100644 index 00000000..f0e5fab0 --- /dev/null +++ b/examples/plugins/sdk-go-hello/manifest.json @@ -0,0 +1,22 @@ +{ + "apiVersion": "gonext.io/v1", + "name": "gonext-sdk-go-hello", + "version": "0.1.0", + "entry": "plugin.wasm", + "capabilities": [ + "kv.write", + "audit.emit", + "hooks.subscribe" + ], + "hooks": { + "actions": [ + "posts.publish" + ], + "filters": [ + "the_content" + ] + }, + "requires": { + "host": ">=0.1.0" + } +} diff --git a/go.work b/go.work index 3f81b60a..be3346f6 100644 --- a/go.work +++ b/go.work @@ -5,5 +5,7 @@ use ( ./apps/worker ./cli/gonext ./examples/plugins/seo + ./examples/plugins/sdk-go-hello ./packages/go + ./packages/go/sdk ) diff --git a/packages/go/sdk/README.md b/packages/go/sdk/README.md new file mode 100644 index 00000000..fad78d10 --- /dev/null +++ b/packages/go/sdk/README.md @@ -0,0 +1,60 @@ +# GoNext Plugin SDK (Go) + +TinyGo-targeted Go SDK for writing GoNext plugins. + +## Hello, world + +```go +package main + +import "github.com/Singleton-Solution/GoNext/packages/go/sdk" + +func main() { + sdk.RegisterAction("posts.publish", func(args []any) error { + sdk.Host.KV.Set("last-published-id", []byte(args[0].(string))) + sdk.Host.Audit.Emit("post.indexed", map[string]any{"id": args[0]}) + return nil + }) + sdk.PluginInit(sdk.NewManifest("hello", "0.1.0"). + WithCapability("kv.write").WithCapability("audit.emit"). + WithAction("posts.publish").MustBuild()) +} +``` + +Build with `tinygo build -target=wasi -no-debug -o plugin.wasm .` and +ship the wasm + a matching `manifest.json` as a `.gnplugin` ZIP. The +`gonext plugin init --template=go` CLI scaffold writes the manifest +and `Makefile` for you. + +## What's in the SDK + +- `RegisterAction(name, handler)` / `RegisterFilter(name, handler)` — + the hook bus mounted via the plugin's `gn_handle_hook` export. +- `sdk.Host.HTTP.Fetch`, `sdk.Host.DB.Read/Write`, `sdk.Host.KV.*`, + `sdk.Host.Cache.Invalidate`, `sdk.Host.Media.Read`, + `sdk.Host.Users.Read`, `sdk.Host.Secrets.Get`, `sdk.Host.Audit.Emit`, + `sdk.Host.Cron.Register`, `sdk.Host.Metric.Observe`, + `sdk.Host.Event.Emit`, `sdk.Host.Span.AddEvent`, + `sdk.Host.I18n.Translate`, `sdk.Host.Log.Info`, `sdk.Host.Time.NowMs` + — typed wrappers over every `gn_*` host ABI. Capability gates, + audit emission, SSRF guards, and rate limits all run host-side. +- `sdk.NewManifest(name, version).With*().Build()` — a fluent + manifest builder. Emits the JSON the host's schema validator + accepts. + +See `examples/plugins/sdk-go-hello/` for a complete worked example +that exercises an action handler, a filter handler, a `gn_kv_set` +call, and a `gn_audit_emit` call. + +## Constraints + +- TinyGo's stdlib is a subset of Go's. Avoid `net/http`, + `database/sql`, `crypto/tls` — the host provides those via the ABIs + above. `encoding/json`, `strings`, `strconv`, `errors`, `sync`, + `unsafe` all work. +- The SDK's own dependency graph is empty (stdlib only) so a plugin's + wasm stays small. A typical hello-world compiles to ~80 KiB + uncompressed. +- The same SDK package compiles under the stock Go toolchain (with + the host-call layer stubbed out) so plugin authors can write + ordinary unit tests against their handler functions. diff --git a/packages/go/sdk/codec.go b/packages/go/sdk/codec.go new file mode 100644 index 00000000..0c28b601 --- /dev/null +++ b/packages/go/sdk/codec.go @@ -0,0 +1,270 @@ +// Package sdk is the GoNext Go plugin SDK, TinyGo-targeted. +// +// A plugin author writes: +// +// package main +// +// import "github.com/Singleton-Solution/GoNext/packages/go/sdk" +// +// func main() { +// sdk.RegisterAction("posts.publish", func(args []any) error { +// sdk.Host.KV.Set("last-published", []byte(args[0].(string))) +// sdk.Host.Audit.Emit("post.indexed", map[string]any{"id": args[0]}) +// return nil +// }) +// sdk.RegisterFilter("the_content", func(value []byte, args []any) ([]byte, error) { +// return append(value, []byte("\n")...), nil +// }) +// sdk.PluginInit(sdk.NewManifest("hello", "0.1.0"). +// WithCapability("kv.write"). +// WithCapability("audit.emit"). +// WithAction("posts.publish"). +// WithFilter("the_content"). +// Build()) +// } +// +// `tinygo build -target=wasi -o plugin.wasm .` produces a module that +// satisfies the v1 hook ABI in packages/go/plugins/abi/hooks — same +// gn_handle_hook / gn_alloc / gn_free / gn_panic exports the host +// expects. +// +// # Wire format +// +// The SDK encodes/decodes payloads on the same JSON envelope shape the +// host uses in packages/go/plugins/abi/hooks/marshal.go. We mirror the +// shapes here rather than depending on the host package so the SDK has +// no transitive imports the TinyGo toolchain can't handle. +// +// # Build modes +// +// codec.go (this file) is pure Go and compiles with both the stock +// toolchain AND TinyGo. Codec unit tests run on the stock toolchain so +// CI can validate marshalling without requiring TinyGo on PATH. +// +// hooks.go, host.go, host_*.go, and runtime_wasm.go contain the +// guest-side runtime that only makes sense inside a wasm32-wasi build. +// They use build tags to switch between a "real" implementation (under +// the wasi tag) and a no-op stub (everywhere else) — so a plugin +// author writing tests against their own handler functions can build +// the package under the stock toolchain too. +package sdk + +import ( + "encoding/json" + "errors" + "fmt" +) + +// PayloadKind tags whether a payload carries action arguments (one +// list) or filter arguments (a value + extras list). Mirrors +// abi/hooks.PayloadKind byte-for-byte; the constant strings are part +// of the v1 wire format. +type PayloadKind string + +const ( + // PayloadKindAction is the action-call shape: a list of args. + PayloadKindAction PayloadKind = "action" + + // PayloadKindFilter is the filter-call shape: a transformable + // value plus the per-call extras. + PayloadKindFilter PayloadKind = "filter" +) + +// ActionPayload mirrors abi/hooks.ActionPayload — the wire form of an +// action-call payload. Kept here so the SDK has no host-package +// imports (TinyGo can't compile the host module). +type ActionPayload struct { + Kind PayloadKind `json:"kind"` + Args []interface{} `json:"args"` +} + +// FilterPayload mirrors abi/hooks.FilterPayload. +type FilterPayload struct { + Kind PayloadKind `json:"kind"` + Value json.RawMessage `json:"value"` + Args []interface{} `json:"args"` +} + +// FilterResult mirrors abi/hooks.FilterResult — the envelope a filter +// handler returns. The host decodes this shape on the way out. +type FilterResult struct { + Value json.RawMessage `json:"value"` +} + +// ResultStatus mirrors abi/hooks.ResultStatus. Sentinels are negative +// int32s the SDK packs into the low half of the i64 result when no +// body is being returned. +type ResultStatus int32 + +const ( + // StatusOK signals success-with-no-body. Returned by every action + // handler that completes without producing a result envelope. + StatusOK ResultStatus = 0 + + // StatusError is the generic guest-reported failure. The SDK + // returns this when a handler returns a non-nil error that isn't + // one of the more specific sentinels below. + StatusError ResultStatus = -1 + + // StatusOutOfMemory signals an allocator failure inside the + // handler. The SDK returns this when gn_alloc would fail. + StatusOutOfMemory ResultStatus = -2 + + // StatusBadPayload signals the SDK couldn't decode the inbound + // payload — a host/guest ABI mismatch. + StatusBadPayload ResultStatus = -3 + + // StatusUnknownHook signals no handler is registered for the + // requested name. The host typically treats this as a stale + // plugin binary. + StatusUnknownHook ResultStatus = -4 +) + +// PackResult composes the i64 the SDK returns from gn_handle_hook +// from a (ptr, len) pair. Pointer occupies the high 32 bits; length +// occupies the low 32 bits as a signed int32 so negative sentinels +// survive the round trip. Mirrors hooks.packResult. +func PackResult(ptr uint32, length int32) uint64 { + return uint64(ptr)<<32 | uint64(uint32(length)) +} + +// UnpackResult is the inverse of PackResult. +func UnpackResult(packed uint64) (ptr uint32, length int32) { + ptr = uint32(packed >> 32) + length = int32(packed & 0xFFFFFFFF) + return ptr, length +} + +// MarshalActionPayload returns the JSON wire bytes for an action call. +// Mirrors hooks.MarshalActionPayload. +func MarshalActionPayload(args []interface{}) ([]byte, error) { + if args == nil { + args = []interface{}{} + } + return json.Marshal(ActionPayload{Kind: PayloadKindAction, Args: args}) +} + +// UnmarshalActionPayload decodes an action-call envelope. Returns an +// error wrapped around ErrBadPayload on a shape mismatch so handlers +// can errors.Is against the sentinel without type-asserting. +func UnmarshalActionPayload(buf []byte) (*ActionPayload, error) { + if len(buf) == 0 { + // The bridge always emits a complete envelope, even for + // zero-arg actions ({"kind":"action","args":[]}). An empty + // buffer is a contract violation; treat as bad payload. + return nil, fmt.Errorf("%w: empty payload", ErrBadPayload) + } + var p ActionPayload + if err := json.Unmarshal(buf, &p); err != nil { + return nil, fmt.Errorf("%w: %v", ErrBadPayload, err) + } + if p.Kind != PayloadKindAction { + return nil, fmt.Errorf("%w: expected %q kind, got %q", ErrBadPayload, PayloadKindAction, p.Kind) + } + if p.Args == nil { + p.Args = []interface{}{} + } + return &p, nil +} + +// MarshalFilterPayload returns the JSON wire bytes for a filter call. +// value MAY be nil — in that case the envelope carries "value":null. +// Mirrors hooks.MarshalFilterPayload. +func MarshalFilterPayload(value json.RawMessage, args []interface{}) ([]byte, error) { + if args == nil { + args = []interface{}{} + } + if value == nil { + value = json.RawMessage("null") + } + return json.Marshal(FilterPayload{Kind: PayloadKindFilter, Value: value, Args: args}) +} + +// UnmarshalFilterPayload decodes a filter-call envelope. Returns an +// error wrapped around ErrBadPayload on a shape mismatch. +func UnmarshalFilterPayload(buf []byte) (*FilterPayload, error) { + if len(buf) == 0 { + return nil, fmt.Errorf("%w: empty payload", ErrBadPayload) + } + var p FilterPayload + if err := json.Unmarshal(buf, &p); err != nil { + return nil, fmt.Errorf("%w: %v", ErrBadPayload, err) + } + if p.Kind != PayloadKindFilter { + return nil, fmt.Errorf("%w: expected %q kind, got %q", ErrBadPayload, PayloadKindFilter, p.Kind) + } + if p.Args == nil { + p.Args = []interface{}{} + } + if len(p.Value) == 0 { + p.Value = json.RawMessage("null") + } + return &p, nil +} + +// MarshalFilterResult produces the wire bytes the host expects from a +// filter handler return — a JSON object with a single "value" key. +func MarshalFilterResult(value json.RawMessage) ([]byte, error) { + if value == nil { + value = json.RawMessage("null") + } + return json.Marshal(FilterResult{Value: value}) +} + +// UnmarshalFilterResult is the inverse: extracts the value bytes from +// a FilterResult envelope. +func UnmarshalFilterResult(buf []byte) (json.RawMessage, error) { + if len(buf) == 0 { + return nil, nil + } + var r FilterResult + if err := json.Unmarshal(buf, &r); err != nil { + return nil, fmt.Errorf("%w: %v", ErrBadPayload, err) + } + if len(r.Value) == 0 { + return json.RawMessage("null"), nil + } + return r.Value, nil +} + +// Sentinel errors callers can errors.Is against. The SDK never wraps +// arbitrary host errors — the failure modes are categorical, and the +// status code carried in the i64 return value is the canonical signal. +// +// Plugin authors that return errors from their handlers get them +// projected onto one of these statuses by the dispatcher in hooks.go. +var ( + // ErrBadPayload is returned from the Unmarshal* helpers when the + // inbound envelope can't be decoded as the expected shape. + ErrBadPayload = errors.New("sdk: bad payload") + + // ErrUnknownHook is returned by the dispatcher when no handler + // matches the inbound name. + ErrUnknownHook = errors.New("sdk: unknown hook") + + // ErrOutOfMemory is returned when the SDK's allocator runs out of + // room. The host translates this to ResultStatusOutOfMemory. + ErrOutOfMemory = errors.New("sdk: out of memory") + + // ErrHostFailure is returned by the typed Host.* wrappers when a + // host ABI call returns a negative status (denied, blocked, rate- + // limited, etc). Inspect the wrapping HostError for the specific + // status. + ErrHostFailure = errors.New("sdk: host ABI returned failure status") +) + +// HostError wraps a host-ABI failure. Status carries the negative +// sentinel the host returned; Function names which host export was +// called. Implements errors.Is against ErrHostFailure. +type HostError struct { + Function string + Status int32 +} + +// Error returns a one-line summary. +func (e *HostError) Error() string { + return fmt.Sprintf("sdk: host call %q failed (status=%d)", e.Function, e.Status) +} + +// Is supports errors.Is(err, ErrHostFailure) for typed matching. +func (e *HostError) Is(target error) bool { return target == ErrHostFailure } diff --git a/packages/go/sdk/codec_test.go b/packages/go/sdk/codec_test.go new file mode 100644 index 00000000..4f031945 --- /dev/null +++ b/packages/go/sdk/codec_test.go @@ -0,0 +1,236 @@ +package sdk + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// codec_test.go validates the wire-format codec the SDK uses to talk +// to the host. The host-side counterpart is exercised by +// packages/go/plugins/abi/hooks/abi_test.go; these tests pin the +// guest side of the same envelope so a regression in either half +// fails its own CI. +// +// We deliberately test the Marshal -> raw bytes path AND the +// host_marshal -> SDK Unmarshal round trip, because the contract is +// symmetric: every byte the host emits must round-trip through our +// decoder, and vice versa. + +func TestPackUnpackResult(t *testing.T) { + cases := []struct { + name string + ptr uint32 + length int32 + }{ + {"zero", 0, 0}, + {"small_positive", 100, 42}, + {"large_pointer", 0xFFFFFF00, 1}, + {"negative_status", 0, -3}, + {"negative_status_with_max_neg", 0, -2147483648}, + {"max_length", 0x1000, 0x7FFFFFFF}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + packed := PackResult(tc.ptr, tc.length) + ptr, length := UnpackResult(packed) + if ptr != tc.ptr || length != tc.length { + t.Fatalf("round-trip mismatch: (%d, %d) -> %#x -> (%d, %d)", + tc.ptr, tc.length, packed, ptr, length) + } + }) + } +} + +func TestMarshalActionPayloadEmpty(t *testing.T) { + // nil args should marshal to an empty-array envelope, NOT null — + // the host's decoder always expects a JSON array. + buf, err := MarshalActionPayload(nil) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"kind":"action","args":[]}` + if string(buf) != want { + t.Fatalf("got %q, want %q", buf, want) + } +} + +func TestMarshalActionPayloadWithArgs(t *testing.T) { + buf, err := MarshalActionPayload([]any{"hello", 42.0, true}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(buf), `"kind":"action"`) { + t.Errorf("missing kind field: %s", buf) + } + if !strings.Contains(string(buf), `"hello",42,true`) { + t.Errorf("args not encoded: %s", buf) + } +} + +func TestUnmarshalActionPayloadRoundTrip(t *testing.T) { + original := []any{"hello", 42.0, true, map[string]any{"k": "v"}} + buf, err := MarshalActionPayload(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + p, err := UnmarshalActionPayload(buf) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if p.Kind != PayloadKindAction { + t.Errorf("kind: got %q, want %q", p.Kind, PayloadKindAction) + } + if len(p.Args) != 4 { + t.Fatalf("args length: got %d, want 4", len(p.Args)) + } + if p.Args[0].(string) != "hello" { + t.Errorf("args[0]: got %v, want hello", p.Args[0]) + } + if p.Args[1].(float64) != 42.0 { + t.Errorf("args[1]: got %v, want 42", p.Args[1]) + } +} + +func TestUnmarshalActionPayloadRejectsBadKind(t *testing.T) { + buf := []byte(`{"kind":"filter","args":[]}`) + _, err := UnmarshalActionPayload(buf) + if !errors.Is(err, ErrBadPayload) { + t.Fatalf("expected ErrBadPayload, got %v", err) + } +} + +func TestUnmarshalActionPayloadRejectsEmpty(t *testing.T) { + _, err := UnmarshalActionPayload(nil) + if !errors.Is(err, ErrBadPayload) { + t.Fatalf("expected ErrBadPayload for empty, got %v", err) + } + _, err = UnmarshalActionPayload([]byte(``)) + if !errors.Is(err, ErrBadPayload) { + t.Fatalf("expected ErrBadPayload for empty string, got %v", err) + } +} + +func TestUnmarshalActionPayloadRejectsInvalidJSON(t *testing.T) { + _, err := UnmarshalActionPayload([]byte(`{not json`)) + if !errors.Is(err, ErrBadPayload) { + t.Fatalf("expected ErrBadPayload for invalid JSON, got %v", err) + } +} + +func TestMarshalFilterPayloadEmptyValue(t *testing.T) { + buf, err := MarshalFilterPayload(nil, nil) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"kind":"filter","value":null,"args":[]}` + if string(buf) != want { + t.Fatalf("got %q, want %q", buf, want) + } +} + +func TestMarshalFilterPayloadWithValueAndArgs(t *testing.T) { + value := json.RawMessage(`"hello"`) + args := []any{42.0} + buf, err := MarshalFilterPayload(value, args) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"kind":"filter","value":"hello","args":[42]}` + if string(buf) != want { + t.Fatalf("got %q, want %q", buf, want) + } +} + +func TestUnmarshalFilterPayloadRoundTrip(t *testing.T) { + // encoding/json HTML-escapes <, >, & in raw messages — that's + // fine on the wire (the host's decoder accepts both) but the + // round-trip assertion has to compare values after a parse, + // not against the raw bytes. + original := json.RawMessage(`"

hi

"`) + buf, err := MarshalFilterPayload(original, []any{42.0}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + p, err := UnmarshalFilterPayload(buf) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if p.Kind != PayloadKindFilter { + t.Errorf("kind: got %q, want %q", p.Kind, PayloadKindFilter) + } + var got string + if err := json.Unmarshal(p.Value, &got); err != nil { + t.Fatalf("unmarshal value: %v", err) + } + if got != "

hi

" { + t.Errorf("value: got %q, want '

hi

'", got) + } + if len(p.Args) != 1 || p.Args[0].(float64) != 42 { + t.Errorf("args: got %v, want [42]", p.Args) + } +} + +func TestUnmarshalFilterPayloadRejectsBadKind(t *testing.T) { + buf := []byte(`{"kind":"action","value":null,"args":[]}`) + _, err := UnmarshalFilterPayload(buf) + if !errors.Is(err, ErrBadPayload) { + t.Fatalf("expected ErrBadPayload, got %v", err) + } +} + +func TestMarshalFilterResultRoundTrip(t *testing.T) { + value := json.RawMessage(`{"title":"updated"}`) + buf, err := MarshalFilterResult(value) + if err != nil { + t.Fatalf("marshal: %v", err) + } + out, err := UnmarshalFilterResult(buf) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if string(out) != `{"title":"updated"}` { + t.Errorf("got %s, want %s", out, value) + } +} + +func TestUnmarshalFilterResultEmpty(t *testing.T) { + out, err := UnmarshalFilterResult(nil) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if out != nil { + t.Errorf("expected nil result for empty input, got %s", out) + } +} + +func TestUnmarshalFilterResultMissingValue(t *testing.T) { + // An envelope with a missing Value field should decode as + // "null" — the SDK's normalisation of "no body". + out, err := UnmarshalFilterResult([]byte(`{}`)) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + if string(out) != `null` { + t.Errorf("got %s, want null", out) + } +} + +func TestHostErrorIs(t *testing.T) { + e := &HostError{Function: "gn_kv_get", Status: -3} + if !errors.Is(e, ErrHostFailure) { + t.Errorf("HostError should match ErrHostFailure") + } + if errors.Is(e, ErrBadPayload) { + t.Errorf("HostError should NOT match ErrBadPayload") + } +} + +func TestHostErrorError(t *testing.T) { + e := &HostError{Function: "gn_kv_get", Status: -3} + want := `sdk: host call "gn_kv_get" failed (status=-3)` + if e.Error() != want { + t.Errorf("got %q, want %q", e.Error(), want) + } +} diff --git a/packages/go/sdk/go.mod b/packages/go/sdk/go.mod new file mode 100644 index 00000000..d6f08530 --- /dev/null +++ b/packages/go/sdk/go.mod @@ -0,0 +1,18 @@ +// Package sdk is the TinyGo-targeted Go plugin SDK for GoNext. +// +// This is its own Go module — separate from packages/go — because the +// host module pulls in pgx, redis, asynq, OTel and other deps that +// TinyGo cannot compile. The SDK has zero non-stdlib deps so a plugin +// author runs `tinygo build -target=wasi -o plugin.wasm .` against a +// module whose entire transitive closure fits inside what TinyGo +// supports. +// +// Stdlib usage is limited to encoding/json, errors, strconv, strings, +// sync, and unsafe — every one of which TinyGo handles on the wasi +// target. We never import net/http (the host provides HTTP via +// gn_http_fetch), database/sql (the host provides db via gn_db_read / +// gn_db_write), or any reflect-heavy package. + +module github.com/Singleton-Solution/GoNext/packages/go/sdk + +go 1.25.0 diff --git a/packages/go/sdk/hooks.go b/packages/go/sdk/hooks.go new file mode 100644 index 00000000..ffb78508 --- /dev/null +++ b/packages/go/sdk/hooks.go @@ -0,0 +1,185 @@ +package sdk + +import ( + "encoding/json" + "sync" +) + +// ActionHandler is the plugin-author-facing signature for an action +// hook handler. The args slice carries whatever the host bus passed +// via Bus.Do — JSON-decoded into []any. Returning a non-nil error +// surfaces as ResultStatusError to the host. +// +// The slice is OWNED by the SDK: handlers MUST NOT retain pointers +// into it past the call. The underlying storage is reused across hook +// dispatches to keep the per-call allocation rate down. +type ActionHandler func(args []any) error + +// FilterHandler is the plugin-author-facing signature for a filter +// hook handler. value is the transformable input as raw JSON bytes; +// the return value is the (also raw JSON) transformed output. args +// carries any per-call extras the host bus added. +// +// Returning a non-nil error rolls the filter chain back: the host +// surfaces it as ResultStatusError and keeps the pre-filter value. +type FilterHandler func(value json.RawMessage, args []any) (json.RawMessage, error) + +// registry is the package-level dispatch table. Built by +// RegisterAction / RegisterFilter at plugin init time, consulted by +// the gn_handle_hook entry point. +// +// We use a sync.RWMutex rather than a sync.Map because: +// - Writes happen exclusively at init; the read side is hot. +// - The host serializes calls into a plugin module so concurrent +// reads are rare in practice, but the mutex lets us claim safety +// without depending on Module.Call's mutex. +var registry = struct { + mu sync.RWMutex + actions map[string]ActionHandler + filters map[string]FilterHandler +}{ + actions: map[string]ActionHandler{}, + filters: map[string]FilterHandler{}, +} + +// RegisterAction binds name -> handler in the dispatch table. Calling +// it twice for the same name overwrites the previous handler — that's +// the documented behaviour so a plugin author can replace handlers +// during development without restarting the process. +// +// The host's lifecycle manager has already validated the action name +// is in the manifest's hooks.actions list before invoking it. Names +// that aren't registered surface as ResultStatusUnknownHook to the +// host. +func RegisterAction(name string, handler ActionHandler) { + if handler == nil { + return + } + registry.mu.Lock() + registry.actions[name] = handler + registry.mu.Unlock() +} + +// RegisterFilter binds name -> handler in the dispatch table. Same +// dupe semantics as RegisterAction. +func RegisterFilter(name string, handler FilterHandler) { + if handler == nil { + return + } + registry.mu.Lock() + registry.filters[name] = handler + registry.mu.Unlock() +} + +// lookupAction returns the registered action handler for name, or nil. +func lookupAction(name string) ActionHandler { + registry.mu.RLock() + h := registry.actions[name] + registry.mu.RUnlock() + return h +} + +// lookupFilter returns the registered filter handler for name, or nil. +func lookupFilter(name string) FilterHandler { + registry.mu.RLock() + h := registry.filters[name] + registry.mu.RUnlock() + return h +} + +// resetRegistry is the test-only helper to clear the dispatch table +// between cases. Not exported; tests inside the package call it via +// the file-name suffix _test.go. +func resetRegistry() { + registry.mu.Lock() + registry.actions = map[string]ActionHandler{} + registry.filters = map[string]FilterHandler{} + registry.mu.Unlock() +} + +// DispatchHook is the kind-agnostic dispatcher the gn_handle_hook +// export calls. It demultiplexes on hookName, decodes the payload as +// either an action or a filter envelope (depending on which handler +// is registered), and returns the (resultBytes, status) pair the +// caller packs into the i64 return. +// +// Exported (PascalCase) because the wasm-target gn_handle_hook +// implementation lives in a separately-compiled file (hooks_wasm.go) +// and needs to reach in from there. +// +// Returns: +// - (resultBytes, StatusOK) on a successful action — resultBytes is +// always nil for an action (no body) but a successful filter +// returns the FilterResult-encoded bytes. +// - (nil, sentinel) on any failure path. The sentinel is the +// negative status the SDK packs into the low half of the i64 +// return. +func DispatchHook(hookName string, payload []byte) ([]byte, ResultStatus) { + if actionHandler := lookupAction(hookName); actionHandler != nil { + p, err := UnmarshalActionPayload(payload) + if err != nil { + return nil, StatusBadPayload + } + if err := actionHandler(p.Args); err != nil { + return nil, StatusError + } + return nil, StatusOK + } + if filterHandler := lookupFilter(hookName); filterHandler != nil { + p, err := UnmarshalFilterPayload(payload) + if err != nil { + return nil, StatusBadPayload + } + newValue, err := filterHandler(p.Value, p.Args) + if err != nil { + return nil, StatusError + } + out, err := MarshalFilterResult(newValue) + if err != nil { + return nil, StatusError + } + return out, StatusOK + } + return nil, StatusUnknownHook +} + +// PluginInit is the entry point a plugin author calls from main(). +// It records the manifest (today only as a logging signal — the +// effective manifest is the one in manifest.json that ships in the +// bundle) so SDK-aware tooling can extract the declared surface from +// the running plugin. +// +// PluginInit MUST be called from main(), AFTER every RegisterAction / +// RegisterFilter call. Calling it before registrations is legal but +// the dispatch table will be incomplete when the host first invokes +// gn_handle_hook. +// +// Today PluginInit is intentionally lightweight: it serves as the +// well-known entry point name plugin authors aim for, and gives the +// SDK a future-proof place to add init-time host calls (e.g. handshake +// with the host, version negotiation, capability self-check) without +// breaking the plugin-author surface. +func PluginInit(m Manifest) { + pluginManifestMu.Lock() + pluginManifest = m + pluginManifestMu.Unlock() +} + +// pluginManifest is the manifest recorded by PluginInit. Today read +// only via Manifest() for debugging/dev tooling; future versions of +// the SDK may use it to drive runtime self-checks. +var ( + pluginManifestMu sync.RWMutex + pluginManifest Manifest +) + +// CurrentManifest returns the manifest the running plugin registered +// via PluginInit. Useful for dev tooling that wants to introspect a +// plugin's declared surface without unpacking the bundle. +// +// Returns the zero Manifest if PluginInit has not yet been called. +func CurrentManifest() Manifest { + pluginManifestMu.RLock() + defer pluginManifestMu.RUnlock() + return pluginManifest +} diff --git a/packages/go/sdk/hooks_test.go b/packages/go/sdk/hooks_test.go new file mode 100644 index 00000000..1ce393f8 --- /dev/null +++ b/packages/go/sdk/hooks_test.go @@ -0,0 +1,150 @@ +package sdk + +import ( + "encoding/json" + "errors" + "testing" +) + +// hooks_test.go validates the dispatcher and registration semantics. +// The wasm-side gn_handle_hook lives in host_wasm.go which has a +// build tag — we exercise DispatchHook directly so this file builds +// under the stock toolchain. + +func TestRegisterAndDispatchAction(t *testing.T) { + resetRegistry() + called := false + var seenArgs []any + RegisterAction("posts.publish", func(args []any) error { + called = true + seenArgs = args + return nil + }) + payload, _ := MarshalActionPayload([]any{"hello", 42.0}) + body, status := DispatchHook("posts.publish", payload) + if status != StatusOK { + t.Fatalf("status: got %d, want OK", status) + } + if body != nil { + t.Errorf("action should return no body, got %s", body) + } + if !called { + t.Error("handler not called") + } + if len(seenArgs) != 2 || seenArgs[0] != "hello" || seenArgs[1].(float64) != 42 { + t.Errorf("args: %v", seenArgs) + } +} + +func TestRegisterAndDispatchFilter(t *testing.T) { + resetRegistry() + RegisterFilter("the_content", func(value json.RawMessage, args []any) (json.RawMessage, error) { + var s string + if err := json.Unmarshal(value, &s); err != nil { + return nil, err + } + s += " (modified)" + return json.Marshal(s) + }) + payload, _ := MarshalFilterPayload(json.RawMessage(`"hi"`), nil) + body, status := DispatchHook("the_content", payload) + if status != StatusOK { + t.Fatalf("status: got %d, want OK", status) + } + out, err := UnmarshalFilterResult(body) + if err != nil { + t.Fatalf("unmarshal result: %v", err) + } + var got string + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("unmarshal value: %v", err) + } + if got != "hi (modified)" { + t.Errorf("got %q, want 'hi (modified)'", got) + } +} + +func TestDispatchHookUnknown(t *testing.T) { + resetRegistry() + _, status := DispatchHook("missing.hook", []byte(`{"kind":"action","args":[]}`)) + if status != StatusUnknownHook { + t.Errorf("got %d, want StatusUnknownHook", status) + } +} + +func TestDispatchHookBadPayload(t *testing.T) { + resetRegistry() + RegisterAction("test", func(args []any) error { return nil }) + _, status := DispatchHook("test", []byte(`{not json`)) + if status != StatusBadPayload { + t.Errorf("got %d, want StatusBadPayload", status) + } +} + +func TestDispatchHookHandlerError(t *testing.T) { + resetRegistry() + RegisterAction("fails", func(args []any) error { + return errors.New("nope") + }) + payload, _ := MarshalActionPayload(nil) + _, status := DispatchHook("fails", payload) + if status != StatusError { + t.Errorf("got %d, want StatusError", status) + } +} + +func TestRegisterActionReplacesHandler(t *testing.T) { + resetRegistry() + calls := []string{} + RegisterAction("test", func(args []any) error { + calls = append(calls, "first") + return nil + }) + RegisterAction("test", func(args []any) error { + calls = append(calls, "second") + return nil + }) + payload, _ := MarshalActionPayload(nil) + _, status := DispatchHook("test", payload) + if status != StatusOK { + t.Fatalf("status: %d", status) + } + if len(calls) != 1 || calls[0] != "second" { + t.Errorf("expected second handler to be called, got %v", calls) + } +} + +func TestRegisterNilHandlerIgnored(t *testing.T) { + resetRegistry() + RegisterAction("nil-handler", nil) + _, status := DispatchHook("nil-handler", []byte(`{"kind":"action","args":[]}`)) + if status != StatusUnknownHook { + t.Errorf("expected UnknownHook for nil handler, got %d", status) + } +} + +func TestFilterHandlerError(t *testing.T) { + resetRegistry() + RegisterFilter("err-filter", func(value json.RawMessage, args []any) (json.RawMessage, error) { + return nil, errors.New("filter failed") + }) + payload, _ := MarshalFilterPayload(json.RawMessage(`"in"`), nil) + _, status := DispatchHook("err-filter", payload) + if status != StatusError { + t.Errorf("got %d, want StatusError", status) + } +} + +func TestPluginInitAndCurrentManifest(t *testing.T) { + m := NewManifest("test-init", "0.5.0"). + WithCapability("kv.write"). + MustBuild() + PluginInit(m) + got := CurrentManifest() + if got.Name != "test-init" || got.Version != "0.5.0" { + t.Errorf("CurrentManifest: %+v", got) + } + if len(got.Capabilities) != 1 || got.Capabilities[0] != "kv.write" { + t.Errorf("capabilities: %v", got.Capabilities) + } +} diff --git a/packages/go/sdk/host.go b/packages/go/sdk/host.go new file mode 100644 index 00000000..6c515624 --- /dev/null +++ b/packages/go/sdk/host.go @@ -0,0 +1,227 @@ +package sdk + +// Host is the top-level handle plugin authors reach through to call +// host ABIs: +// +// resp, err := sdk.Host.HTTP.Fetch(sdk.HTTPRequest{URL: "https://api.example.com/x"}) +// err := sdk.Host.KV.Set("counter", []byte("42")) +// rows, err := sdk.Host.DB.Read("SELECT id FROM posts WHERE published = true", nil) +// +// Each field is a typed surface over one ABI domain. The implementations +// live in host_*.go files split by domain; this file is the assembly +// point so plugin authors have a single value to chain from. +// +// The struct is a package-level singleton because every host export is +// a global wasm import — there's no concept of "two hosts" inside one +// guest module. Constructing a Host yourself is a no-op; use the +// package-level Host variable. +var Host = HostAPI{ + HTTP: HTTPAPI{}, + DB: DBAPI{}, + KV: KVAPI{}, + Cache: CacheAPI{}, + Media: MediaAPI{}, + Users: UsersAPI{}, + Secrets: SecretsAPI{}, + Audit: AuditAPI{}, + Cron: CronAPI{}, + Metric: MetricAPI{}, + Event: EventAPI{}, + Span: SpanAPI{}, + I18n: I18nAPI{}, + Log: LogAPI{}, + Time: TimeAPI{}, +} + +// HostAPI is the typed root. Plugin authors don't construct this +// directly — they reach it via the package-level Host singleton. +type HostAPI struct { + // HTTP wraps gn_http_fetch. The plugin's manifest must declare + // the http.fetch capability and list allowed hosts in + // http.fetch.allow_hosts; the host SSRF-guards every call. + HTTP HTTPAPI + + // DB wraps gn_db_read and gn_db_write. The plugin's manifest + // must declare db.read / db.write capabilities; the host + // enforces a SELECT/WITH allowlist on reads and an INSERT/ + // UPDATE/DELETE allowlist on writes. + DB DBAPI + + // KV wraps gn_kv_get, gn_kv_set, gn_kv_del, gn_kv_incr. Keys + // are namespaced per-plugin by the host; the plugin sees its + // own bare keys. + KV KVAPI + + // Cache wraps gn_cache_invalidate. Tags are persisted into the + // cache_invalidations outbox; a worker drains them. + Cache CacheAPI + + // Media wraps gn_media_read — returns metadata plus a short-TTL + // signed URL for the asset. + Media MediaAPI + + // Users wraps gn_users_read — returns the user row with fields + // filtered against the manifest's users.read scope. + Users UsersAPI + + // Secrets wraps gn_secrets_get — returns the value for one + // secret key the manifest declared in secrets.read. + Secrets SecretsAPI + + // Audit wraps gn_audit_emit — emits one audit row tagged with + // the plugin slug. + Audit AuditAPI + + // Cron wraps gn_cron_register — declares a cron schedule for + // a job id the plugin owns. + Cron CronAPI + + // Metric wraps gn_metric_observe — records one observation + // against a (slug, metric, tags) tuple. Tags are + // cardinality-bounded per plugin. + Metric MetricAPI + + // Event wraps gn_event_emit — emits one structured event row + // into the audit log at info severity. + Event EventAPI + + // Span wraps gn_span_event — attaches a named event to the + // currently-active OTel span. + Span SpanAPI + + // I18n wraps gn_i18n_translate — returns the localised string + // for (key, locale), or the key if no translation exists. + I18n I18nAPI + + // Log wraps gn_log — emits a host-side structured log line. + Log LogAPI + + // Time wraps gn_time_ms — returns the host's idea of current + // wall-clock milliseconds. + Time TimeAPI +} + +// ============================================================================ +// Wire envelopes shared across the domain wrappers. +// ============================================================================ + +// HTTPRequest is the input shape for Host.HTTP.Fetch. Mirrors the +// host's httpFetchRequest exactly so the SDK marshals straight into +// the wire format. +type HTTPRequest struct { + // Method is the HTTP verb (GET, POST, PUT, PATCH, DELETE). + // Empty means GET. + Method string `json:"method,omitempty"` + + // URL is the full target URL. Must be https-or-http, and the + // hostname must appear in the manifest's http.fetch.allow_hosts + // list — the host rejects anything else. + URL string `json:"url"` + + // Headers is the set of request headers. The host strips + // Host, Cookie, Authorization, Content-Length, and + // Transfer-Encoding so a plugin can't impersonate the host's + // identity to upstreams. + Headers map[string]string `json:"headers,omitempty"` + + // Body is the request body. Nil for verbs that don't carry one. + Body []byte `json:"body,omitempty"` +} + +// HTTPResponse is the result shape from Host.HTTP.Fetch. +type HTTPResponse struct { + // Status is the HTTP status code. 0 on transport-level failure. + Status int `json:"status"` + + // Headers is the response header map. Multi-value headers are + // joined with ", " on the wire. + Headers map[string]string `json:"headers,omitempty"` + + // Body is the response body, capped at the host's + // MaxHTTPFetchResponseBytes (10 MiB). + Body []byte `json:"body,omitempty"` + + // Error is set when the host detected a transport-level + // failure (DNS, TLS, redirect loop). Empty when Status carries + // a real upstream code, even for 4xx/5xx. + Error string `json:"error,omitempty"` +} + +// MediaAsset is the result shape from Host.Media.Read. Mirrors the +// host's MediaAsset exactly. +type MediaAsset struct { + ID string `json:"id"` + MimeType string `json:"mime_type,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + SignedURL string `json:"signed_url"` + ExpiresAt string `json:"expires_at,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// LogLevel mirrors the host's log levels (matched to slog). +type LogLevel int32 + +const ( + // LogDebug is the most verbose level. + LogDebug LogLevel = 0 + + // LogInfo is the default level. + LogInfo LogLevel = 1 + + // LogWarn is for non-fatal anomalies. + LogWarn LogLevel = 2 + + // LogError is for failures the operator should see. + LogError LogLevel = 3 +) + +// ============================================================================ +// Per-domain wrappers. The methods themselves are declared in this file +// for the API surface; the actual host-call plumbing is in the +// host_wasm.go / host_stub.go pair (build-tag-gated). +// ============================================================================ + +// HTTPAPI is the typed wrapper over gn_http_fetch. +type HTTPAPI struct{} + +// DBAPI is the typed wrapper over gn_db_read / gn_db_write. +type DBAPI struct{} + +// KVAPI is the typed wrapper over gn_kv_get / gn_kv_set / gn_kv_del / gn_kv_incr. +type KVAPI struct{} + +// CacheAPI is the typed wrapper over gn_cache_invalidate. +type CacheAPI struct{} + +// MediaAPI is the typed wrapper over gn_media_read. +type MediaAPI struct{} + +// UsersAPI is the typed wrapper over gn_users_read. +type UsersAPI struct{} + +// SecretsAPI is the typed wrapper over gn_secrets_get. +type SecretsAPI struct{} + +// AuditAPI is the typed wrapper over gn_audit_emit. +type AuditAPI struct{} + +// CronAPI is the typed wrapper over gn_cron_register. +type CronAPI struct{} + +// MetricAPI is the typed wrapper over gn_metric_observe. +type MetricAPI struct{} + +// EventAPI is the typed wrapper over gn_event_emit. +type EventAPI struct{} + +// SpanAPI is the typed wrapper over gn_span_event. +type SpanAPI struct{} + +// I18nAPI is the typed wrapper over gn_i18n_translate. +type I18nAPI struct{} + +// LogAPI is the typed wrapper over gn_log. +type LogAPI struct{} + +// TimeAPI is the typed wrapper over gn_time_ms. +type TimeAPI struct{} diff --git a/packages/go/sdk/host_methods.go b/packages/go/sdk/host_methods.go new file mode 100644 index 00000000..c7cbf0f1 --- /dev/null +++ b/packages/go/sdk/host_methods.go @@ -0,0 +1,455 @@ +package sdk + +import ( + "encoding/json" + "fmt" +) + +// This file owns the typed methods that hang off the Host.* surface. +// Every method follows the same shape: +// +// 1. JSON-marshal the typed input into the wire envelope the host +// expects (or skip the marshal if the ABI takes raw bytes). +// 2. Call the low-level hostCall primitive (defined in +// host_wasm.go for the wasm32-wasi target, host_stub.go elsewhere). +// 3. Decode the host's response — either a JSON envelope or a raw +// byte buffer — and map it onto the typed return shape. +// +// Splitting "typed method" from "raw host call" keeps the wasmimport +// signatures small (i32/i64 only) and the test surface independent of +// TinyGo. + +// ============================================================================ +// HTTP +// ============================================================================ + +// Fetch issues a single outbound HTTP request through gn_http_fetch. +// The plugin must declare http.fetch in its manifest and list the +// target hostname under http.fetch.allow_hosts; the host rejects +// anything else with a denied / blocked status. +// +// Returns a typed HTTPResponse on the success path. On any host- +// detected failure (denied, blocked, rate-limited, upstream error) +// the returned error wraps ErrHostFailure and carries the specific +// negative status in a HostError. +func (HTTPAPI) Fetch(req HTTPRequest) (*HTTPResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("sdk: marshal http request: %w", err) + } + respBytes, status := hostCallHTTPFetch(body) + if status < 0 { + return nil, &HostError{Function: "gn_http_fetch", Status: status} + } + var resp HTTPResponse + if err := json.Unmarshal(respBytes, &resp); err != nil { + return nil, fmt.Errorf("sdk: decode http response: %w", err) + } + return &resp, nil +} + +// ============================================================================ +// DB +// ============================================================================ + +// Read runs a SELECT (or WITH ...) query through gn_db_read. The host +// validates the query lexically (no DDL, no multi-statement) and +// applies the plugin's bound role before execution. +// +// Returns the rowset as a slice of column-name -> value maps. Empty +// rowsets come back as a non-nil zero-length slice. +func (DBAPI) Read(query string, args []any) ([]map[string]any, error) { + argsBuf, err := marshalDBArgs(args) + if err != nil { + return nil, err + } + respBytes, status := hostCallDBRead([]byte(query), argsBuf) + if status < 0 { + return nil, &HostError{Function: "gn_db_read", Status: status} + } + if len(respBytes) == 0 { + return []map[string]any{}, nil + } + var rows []map[string]any + if err := json.Unmarshal(respBytes, &rows); err != nil { + return nil, fmt.Errorf("sdk: decode db rows: %w", err) + } + if rows == nil { + rows = []map[string]any{} + } + return rows, nil +} + +// Write runs an INSERT/UPDATE/DELETE query through gn_db_write. +// Returns the number of affected rows; the host caps the return at +// math.MaxInt32 if the underlying tag claims more. +func (DBAPI) Write(query string, args []any) (int32, error) { + argsBuf, err := marshalDBArgs(args) + if err != nil { + return 0, err + } + _, status := hostCallDBWrite([]byte(query), argsBuf) + if status < 0 { + return 0, &HostError{Function: "gn_db_write", Status: status} + } + // Successful db.write packs the affected-row count into the low + // 32 bits with ptr=0. The status return IS the affected count + // when non-negative; non-positive means "exactly zero rows" or + // a sentinel. + return status, nil +} + +// marshalDBArgs encodes a []any into a JSON array, returning nil for +// nil/empty input so the host sees argsLen=0. +func marshalDBArgs(args []any) ([]byte, error) { + if len(args) == 0 { + return nil, nil + } + out, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("sdk: marshal db args: %w", err) + } + return out, nil +} + +// ============================================================================ +// KV +// ============================================================================ + +// Get retrieves the value for key under the plugin's namespace. Returns +// (nil, nil) when the key is not present — the absence is not an error. +func (KVAPI) Get(key string) ([]byte, error) { + respBytes, status := hostCallKVGet([]byte(key)) + if status == kvStatusNotFound { + return nil, nil + } + if status < 0 && status != int32(StatusOK) { + return nil, &HostError{Function: "gn_kv_get", Status: status} + } + if len(respBytes) == 0 { + return nil, nil + } + return respBytes, nil +} + +// Set stores value under key in the plugin's namespace. Returns nil +// on success. +// +// The host caps individual values at 256 KiB and enforces the plugin's +// declared storage.kv.max_bytes / max_keys quotas. When the quota would +// be exceeded the host evicts the oldest keys; only an unrecoverable +// quota overflow (the new value alone is larger than max_bytes) surfaces +// as a dataQuotaExceeded error. +func (KVAPI) Set(key string, value []byte) error { + _, status := hostCallKVSet([]byte(key), value) + if status < 0 && status != int32(StatusOK) { + return &HostError{Function: "gn_kv_set", Status: status} + } + return nil +} + +// Del removes the value for key. Idempotent — deleting a missing key +// is a no-op success. +func (KVAPI) Del(key string) error { + _, status := hostCallKVDel([]byte(key)) + if status < 0 && status != int32(StatusOK) { + return &HostError{Function: "gn_kv_del", Status: status} + } + return nil +} + +// Incr atomically increments the counter at key by delta. delta may +// be negative. Returns the new value. +func (KVAPI) Incr(key string, delta int64) (int32, error) { + _, status := hostCallKVIncr([]byte(key), delta) + if status < 0 && status != int32(StatusOK) { + return 0, &HostError{Function: "gn_kv_incr", Status: status} + } + return status, nil +} + +// kvStatusNotFound mirrors the host's dataResultNotFound. Spelled out +// in this file so the high-level Get can distinguish "missing" from a +// generic failure without pulling the host package's constants over. +const kvStatusNotFound int32 = -4 + +// ============================================================================ +// Cache +// ============================================================================ + +// Invalidate enqueues invalidation for one or more cache tags. The +// host persists them into the cache_invalidations outbox; a worker +// drains the table into Redis pub/sub. +// +// Tags are persisted UN-prefixed; the worker namespaces them as +// plugin:: on publish. Pass them however the plugin sees +// them. +func (CacheAPI) Invalidate(tags ...string) error { + if len(tags) == 0 { + return nil + } + buf, err := json.Marshal(tags) + if err != nil { + return fmt.Errorf("sdk: marshal cache tags: %w", err) + } + _, status := hostCallCacheInvalidate(buf) + if status < 0 && status != int32(StatusOK) { + return &HostError{Function: "gn_cache_invalidate", Status: status} + } + return nil +} + +// ============================================================================ +// Media +// ============================================================================ + +// Read returns metadata + a signed URL for the media asset identified +// by id. Returns (nil, nil) for an unknown id. +func (MediaAPI) Read(id string) (*MediaAsset, error) { + req, err := json.Marshal(struct { + ID string `json:"id"` + }{ID: id}) + if err != nil { + return nil, fmt.Errorf("sdk: marshal media request: %w", err) + } + respBytes, status := hostCallMediaRead(req) + if status == kvStatusNotFound { + return nil, nil + } + if status < 0 { + return nil, &HostError{Function: "gn_media_read", Status: status} + } + var asset MediaAsset + if err := json.Unmarshal(respBytes, &asset); err != nil { + return nil, fmt.Errorf("sdk: decode media asset: %w", err) + } + return &asset, nil +} + +// ============================================================================ +// Users +// ============================================================================ + +// Read returns the user row for id, filtered against the manifest's +// users.read field allowlist by the host. Returns (nil, nil) when no +// user with that id exists. +func (UsersAPI) Read(id string) (map[string]any, error) { + req, err := json.Marshal(struct { + ID string `json:"id"` + }{ID: id}) + if err != nil { + return nil, fmt.Errorf("sdk: marshal users request: %w", err) + } + respBytes, status := hostCallUsersRead(req) + if status == kvStatusNotFound { + return nil, nil + } + if status < 0 { + return nil, &HostError{Function: "gn_users_read", Status: status} + } + var row map[string]any + if err := json.Unmarshal(respBytes, &row); err != nil { + return nil, fmt.Errorf("sdk: decode users row: %w", err) + } + return row, nil +} + +// ============================================================================ +// Secrets +// ============================================================================ + +// Get returns the value for one secret key the manifest declared +// under secrets.read. Returns (nil, nil) when the key is not present +// in the host's vault. +func (SecretsAPI) Get(key string) ([]byte, error) { + respBytes, status := hostCallSecretsGet([]byte(key)) + if status == kvStatusNotFound { + return nil, nil + } + if status < 0 && status != int32(StatusOK) { + return nil, &HostError{Function: "gn_secrets_get", Status: status} + } + if len(respBytes) == 0 { + return nil, nil + } + return respBytes, nil +} + +// ============================================================================ +// Audit +// ============================================================================ + +// Emit records one audit row tagged with the plugin slug. eventType +// is the dotted event name (e.g. "post.indexed", "user.notified"). +// metadata is the structured payload — every value must be a type +// encoding/json can render (string, bool, number, nested map/slice). +// +// Returns an error if the metadata blob fails to marshal or the host +// reports a failure status. The audit ABI is best-effort host-side; +// most failures are visible to the operator via host logs. +func (AuditAPI) Emit(eventType string, metadata map[string]any) error { + payload, err := json.Marshal(struct { + Event string `json:"event"` + Metadata map[string]any `json:"metadata,omitempty"` + }{Event: eventType, Metadata: metadata}) + if err != nil { + return fmt.Errorf("sdk: marshal audit payload: %w", err) + } + _, status := hostCallAuditEmit(payload) + if status < 0 && status != int32(StatusOK) { + return &HostError{Function: "gn_audit_emit", Status: status} + } + return nil +} + +// ============================================================================ +// Cron +// ============================================================================ + +// Register declares a cron schedule that triggers jobID on the host's +// scheduler. spec is a standard cron expression ("0 */1 * * *", +// "@hourly"). jobID must be one of the ids declared in the manifest's +// jobs[] list. +// +// Register is idempotent — calling it twice for the same (spec, jobID) +// pair is a no-op on the second call. +func (CronAPI) Register(spec, jobID string) error { + payload, err := json.Marshal(struct { + Spec string `json:"spec"` + JobID string `json:"job_id"` + }{Spec: spec, JobID: jobID}) + if err != nil { + return fmt.Errorf("sdk: marshal cron payload: %w", err) + } + _, status := hostCallCronRegister(payload) + if status < 0 && status != int32(StatusOK) { + return &HostError{Function: "gn_cron_register", Status: status} + } + return nil +} + +// ============================================================================ +// Metrics, Events, Spans +// ============================================================================ + +// Observe records one metric observation. The (slug, name, tags) +// tuple is checked against the host's cardinality dam — a noisy +// plugin emitting unbounded tag values gets its excess observations +// dropped with a `plugin.metric_cardinality_exceeded` audit warning. +// +// Tag keys and values MUST be strings; richer types should be +// stringified at the SDK boundary. The host's tag encoding is a +// minimal msgpack subset (string-keyed string-valued maps). +func (MetricAPI) Observe(name string, value float64, tags map[string]string) error { + tagsBuf := encodeStringMap(tags) + status := hostCallMetricObserve([]byte(name), value, tagsBuf) + if status < 0 { + return &HostError{Function: "gn_metric_observe", Status: status} + } + return nil +} + +// Emit records a structured event row into the audit log at info +// severity. data is the structured payload; keys and values must be +// strings (same encoding as MetricAPI.Observe tags). Use AuditAPI.Emit +// instead when the payload needs richer types or a non-info severity. +func (EventAPI) Emit(name string, data map[string]string) error { + dataBuf := encodeStringMap(data) + status := hostCallEventEmit([]byte(name), dataBuf) + if status < 0 { + return &HostError{Function: "gn_event_emit", Status: status} + } + return nil +} + +// AddEvent attaches a named event to the currently-active OTel span +// for this plugin. attrs is the event's attribute set; keys and values +// must be strings. When no span is active, the host logs the event at +// debug level so authors developing without an OTel pipeline still +// see breadcrumbs. +func (SpanAPI) AddEvent(name string, attrs map[string]string) error { + attrsBuf := encodeStringMap(attrs) + status := hostCallSpanEvent([]byte(name), attrsBuf) + if status < 0 { + return &HostError{Function: "gn_span_event", Status: status} + } + return nil +} + +// ============================================================================ +// I18n +// ============================================================================ + +// Translate returns the localised string for (key, locale). Falls back +// to the key itself when no translation exists — matching the host's +// behaviour. Plugin code can rely on Translate always returning a +// non-empty string. +func (I18nAPI) Translate(key, locale string) string { + out, _ := hostCallI18nTranslate([]byte(key), []byte(locale)) + if len(out) == 0 { + return key + } + return string(out) +} + +// ============================================================================ +// Log + Time +// ============================================================================ + +// Log emits a host-side structured log line at the given level. The +// host prefixes the entry with the plugin slug. This is a fire-and- +// forget call — the host best-effort-publishes to slog and to any +// dev-CLI log streamer. +func (LogAPI) Log(level LogLevel, message string) { + hostCallLog(int32(level), []byte(message)) +} + +// Debug is shorthand for Log(LogDebug, message). +func (l LogAPI) Debug(message string) { l.Log(LogDebug, message) } + +// Info is shorthand for Log(LogInfo, message). +func (l LogAPI) Info(message string) { l.Log(LogInfo, message) } + +// Warn is shorthand for Log(LogWarn, message). +func (l LogAPI) Warn(message string) { l.Log(LogWarn, message) } + +// Error is shorthand for Log(LogError, message). +func (l LogAPI) Error(message string) { l.Log(LogError, message) } + +// NowMs returns the host's idea of current wall-clock milliseconds +// (the same value time.Now().UnixMilli() would produce host-side). +// Plugins running under WithTimeSource see whatever the host injects; +// production builds see real wall-clock. +func (TimeAPI) NowMs() int64 { + return hostCallTimeMs() +} + +// ============================================================================ +// String-map encoder (matches the host's minimal msgpack subset). +// ============================================================================ + +// encodeStringMap encodes a map[string]string into the minimal-msgpack +// shape the host's readHostTags expects: map16 header + str16-keyed +// entries. Plugins that want richer tag types should stringify at the +// SDK boundary. +// +// We deliberately emit the always-2-byte header forms (map16, str16) +// even for small maps so the encoder is straight-line — no branching +// on count. The host's decoder accepts both fixmap and map16 forms. +// +// nil/empty input returns nil so the host sees tags_len=0 and treats +// the call as untagged. +func encodeStringMap(m map[string]string) []byte { + if len(m) == 0 { + return nil + } + out := make([]byte, 0, 32+8*len(m)) + out = append(out, 0xde, byte(len(m)>>8), byte(len(m))) + for k, v := range m { + out = append(out, 0xda, byte(len(k)>>8), byte(len(k))) + out = append(out, k...) + out = append(out, 0xda, byte(len(v)>>8), byte(len(v))) + out = append(out, v...) + } + return out +} diff --git a/packages/go/sdk/host_stub.go b/packages/go/sdk/host_stub.go new file mode 100644 index 00000000..cd72b444 --- /dev/null +++ b/packages/go/sdk/host_stub.go @@ -0,0 +1,80 @@ +//go:build !wasm && !tinygo.wasm + +// host_stub.go provides the host-call surface when the SDK is compiled +// with the stock Go toolchain (not TinyGo + wasi). It exists so: +// +// - Plugin authors can write unit tests for their handler logic that +// compile under `go test`, without TinyGo installed. +// +// - The codec tests in this package can build and run in CI without +// a wasm toolchain. +// +// - Lint and static analysis tools that don't understand TinyGo's +// build tags can still see the full SDK surface. +// +// In this build, every hostCall* function returns a stub failure status +// (statusHostUnavailable). Tests that need to verify guest-side +// marshalling stick to the Marshal/Unmarshal helpers in codec.go and +// dispatch helpers in hooks.go; tests that want to verify host-call +// shaping go through the real wasm path (sdk-go-hello example). + +package sdk + +// statusHostUnavailable is the stub status returned by every host +// call in this build. It's a negative int32 that doesn't collide with +// any real host status — picked outside the (-1..-10) range each +// domain uses. +const statusHostUnavailable int32 = -100 + +// HostUnavailable is the sentinel HostError every stub returns. Tests +// that want to assert "the SDK reached the host" use +// errors.Is(err, ErrHostFailure) and then check +// HostError.Status == StatusHostUnavailable. +const StatusHostUnavailable = statusHostUnavailable + +// ============================================================================ +// HTTP / DB / KV / Cache / Media / Users / Secrets stubs. +// ============================================================================ + +func hostCallHTTPFetch(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallDBRead(_, _ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallDBWrite(_, _ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallKVGet(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallKVSet(_, _ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallKVDel(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallKVIncr(_ []byte, _ int64) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallCacheInvalidate(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallMediaRead(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallUsersRead(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallSecretsGet(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallAuditEmit(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallCronRegister(_ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallMetricObserve(_ []byte, _ float64, _ []byte) int32 { return statusHostUnavailable } +func hostCallEventEmit(_, _ []byte) int32 { return statusHostUnavailable } +func hostCallSpanEvent(_, _ []byte) int32 { return statusHostUnavailable } +func hostCallI18nTranslate(_, _ []byte) ([]byte, int32) { return nil, statusHostUnavailable } +func hostCallLog(_ int32, _ []byte) {} +func hostCallTimeMs() int64 { return 0 } + +// ============================================================================ +// Allocator stubs. +// +// On the stock toolchain, the SDK is never asked to satisfy gn_alloc / +// gn_free / gn_handle_hook from the host — there's no host. We still +// define the exported names so the build succeeds; they panic if +// reached. +// ============================================================================ + +// guestAlloc is the SDK's allocator. Under wasm32-wasi it's wired to a +// real bump arena exported as gn_alloc; under stock Go it's an +// unreachable stub. +func guestAlloc(size uint32) uint32 { + _ = size + return 0 +} + +// guestFree is the SDK's deallocator. Same situation as guestAlloc. +func guestFree(ptr, size uint32) { + _ = ptr + _ = size +} diff --git a/packages/go/sdk/host_wasm.go b/packages/go/sdk/host_wasm.go new file mode 100644 index 00000000..b8665f1c --- /dev/null +++ b/packages/go/sdk/host_wasm.go @@ -0,0 +1,391 @@ +//go:build (wasm || tinygo.wasm) && !sdk_disable_wasm + +// host_wasm.go is the real host-call layer compiled into a TinyGo +// wasm32-wasi build. It uses //go:wasmimport to bind each host export +// to a Go function whose calling convention is i32/i64/f64 only — +// pointers are passed as uint32 offsets into linear memory and +// lengths as uint32 byte counts. +// +// The shape we use is the same the host advertises: +// +// gn_http_fetch(req_ptr, req_len) -> i64 +// gn_db_read(query_ptr, query_len, args_ptr, args_len) -> i64 +// gn_kv_get(key_ptr, key_len) -> i64 +// ... +// +// Each returned i64 packs (ptr<<32 | len). A negative length signals +// a typed sentinel; a non-negative length means the host wrote a +// result envelope at ptr that the SDK reads back via unsafe.Slice and +// frees via gn_free. +// +// For the metric / event / span exports the host returns a plain i32 +// status — no body — so we use a simpler wrapper. + +package sdk + +import ( + "sync" + "unsafe" +) + +// ============================================================================ +// Allocator. +// +// We export gn_alloc / gn_free to the host. Strategy: keep a slice of +// Go byte slices alive; gn_alloc grows the slice and returns the +// address of its first byte. gn_free is a no-op — under this plugin's +// usage pattern the per-call data is bounded by MaxPayloadBytes +// (1 MiB) and the host calls gn_free on success but we deliberately +// retain the buffer so a TinyGo GC sweep doesn't reclaim it +// concurrently with a host-side read. +// +// A real bump-arena allocator would be cheaper but the simpler +// strategy is correct and shippable. Operators with strict memory +// budgets can replace the allocator by linking against a custom +// build that defines its own gn_alloc / gn_free with the +// sdk_disable_wasm build tag and a sibling implementation file. +// ============================================================================ + +var ( + allocMu sync.Mutex + allocBufs [][]byte +) + +//go:wasmexport gn_alloc +func gn_alloc(size uint32) uint32 { + return guestAlloc(size) +} + +//go:wasmexport gn_free +func gn_free(ptr uint32, size uint32) { + guestFree(ptr, size) +} + +func guestAlloc(size uint32) uint32 { + if size == 0 { + // 0-byte allocations are legal but pointless; return a + // stable non-zero pointer so the host doesn't mistake the + // result for OOM. The pointer doesn't need to be valid + // memory — the host only reads zero bytes through it. + return 1 + } + allocMu.Lock() + defer allocMu.Unlock() + buf := make([]byte, size) + allocBufs = append(allocBufs, buf) + return uint32(uintptr(unsafe.Pointer(&buf[0]))) +} + +func guestFree(_ uint32, _ uint32) { + // no-op; see the allocator strategy comment above. +} + +// readGuest returns a byte slice that aliases the wasm linear memory +// at (ptr, length). The slice MUST NOT outlive the host call that +// passed the pointer in — TinyGo may relocate buffers across GC, and +// the host re-uses the buffer space for the next call. +func readGuest(ptr uint32, length uint32) []byte { + if length == 0 { + return nil + } + return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), int(length)) +} + +// writeAlloc allocates a fresh guest buffer via guestAlloc and copies +// b into it. Returns the address; 0 on allocation failure. +func writeAlloc(b []byte) uint32 { + if len(b) == 0 { + return 0 + } + ptr := guestAlloc(uint32(len(b))) + if ptr == 0 { + return 0 + } + dst := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), len(b)) + copy(dst, b) + return ptr +} + +// unpackHostResult is the i64-decoder counterpart to PackResult. The +// host returns (ptr<<32 | int32_len). A negative length is a status +// sentinel and ptr is 0; a non-negative length means the host wrote +// a real buffer at ptr. +func unpackHostResult(packed uint64) (data []byte, status int32) { + ptr := uint32(packed >> 32) + length := int32(packed & 0xFFFFFFFF) + if length < 0 { + return nil, length + } + if ptr == 0 || length == 0 { + return nil, 0 + } + // Copy out: the host's writeGuestPayload allocated this via our + // gn_alloc, so the bytes live in our linear memory and survive + // past the host call. We still copy to keep the SDK contract + // uniform with the no-body path. + src := readGuest(ptr, uint32(length)) + out := make([]byte, length) + copy(out, src) + return out, 0 +} + +// ============================================================================ +// Host imports. +// +// Module names: +// env — gn_log, gn_panic, gn_time_ms, gn_i18n_translate, +// gn_metric_observe, gn_event_emit, gn_span_event, +// gn_audit_emit, gn_cron_register, gn_secrets_get. +// env_net — gn_http_fetch, gn_media_read, gn_users_read. +// gonext_data — gn_db_read, gn_db_write, gn_kv_*, gn_cache_invalidate. +// +// The split matches the host's actual instantiation: env is the +// baseline host module; env_net was added in PR #454 (network ABI); +// gonext_data was added in PR #455 (data ABI). +// ============================================================================ + +//go:wasmimport env_net gn_http_fetch +func envNetHTTPFetch(reqPtr, reqLen uint32) uint64 + +//go:wasmimport env_net gn_media_read +func envNetMediaRead(reqPtr, reqLen uint32) uint64 + +//go:wasmimport env_net gn_users_read +func envNetUsersRead(reqPtr, reqLen uint32) uint64 + +//go:wasmimport gonext_data gn_db_read +func gonextDataDBRead(queryPtr, queryLen, argsPtr, argsLen uint32) uint64 + +//go:wasmimport gonext_data gn_db_write +func gonextDataDBWrite(queryPtr, queryLen, argsPtr, argsLen uint32) uint64 + +//go:wasmimport gonext_data gn_kv_get +func gonextDataKVGet(keyPtr, keyLen uint32) uint64 + +//go:wasmimport gonext_data gn_kv_set +func gonextDataKVSet(keyPtr, keyLen, valPtr, valLen uint32) uint64 + +//go:wasmimport gonext_data gn_kv_del +func gonextDataKVDel(keyPtr, keyLen uint32) uint64 + +//go:wasmimport gonext_data gn_kv_incr +func gonextDataKVIncr(keyPtr, keyLen uint32, delta int64) uint64 + +//go:wasmimport gonext_data gn_cache_invalidate +func gonextDataCacheInvalidate(tagsPtr, tagsLen uint32) uint64 + +//go:wasmimport env gn_log +func envLog(level int32, ptr, length uint32) + +//go:wasmimport env gn_time_ms +func envTimeMs() int64 + +//go:wasmimport env gn_i18n_translate +func envI18nTranslate(keyPtr, keyLen, localePtr, localeLen uint32) uint64 + +//go:wasmimport env gn_metric_observe +func envMetricObserve(namePtr, nameLen uint32, value float64, tagsPtr, tagsLen uint32) int32 + +//go:wasmimport env gn_event_emit +func envEventEmit(namePtr, nameLen, dataPtr, dataLen uint32) int32 + +//go:wasmimport env gn_span_event +func envSpanEvent(namePtr, nameLen, attrsPtr, attrsLen uint32) int32 + +//go:wasmimport env gn_audit_emit +func envAuditEmit(payloadPtr, payloadLen uint32) int32 + +//go:wasmimport env gn_cron_register +func envCronRegister(payloadPtr, payloadLen uint32) int32 + +//go:wasmimport env gn_secrets_get +func envSecretsGet(keyPtr, keyLen uint32) uint64 + +// ============================================================================ +// hostCall* — the typed shims host_methods.go consumes. Each one +// writes the input buffers into guest linear memory, calls the +// wasmimport, and unpacks the result. +// ============================================================================ + +// hostCallHTTPFetch issues gn_http_fetch and returns (responseBytes, +// status). A non-negative status means the host wrote a response +// envelope (responseBytes is non-nil). A negative status is one of +// the NetResultStatus sentinels (denied, blocked, rate-limited, ...). +func hostCallHTTPFetch(body []byte) ([]byte, int32) { + ptr, length := bytesToWasm(body) + packed := envNetHTTPFetch(ptr, length) + return unpackHostResult(packed) +} + +func hostCallMediaRead(body []byte) ([]byte, int32) { + ptr, length := bytesToWasm(body) + packed := envNetMediaRead(ptr, length) + return unpackHostResult(packed) +} + +func hostCallUsersRead(body []byte) ([]byte, int32) { + ptr, length := bytesToWasm(body) + packed := envNetUsersRead(ptr, length) + return unpackHostResult(packed) +} + +func hostCallDBRead(query, args []byte) ([]byte, int32) { + qp, ql := bytesToWasm(query) + ap, al := bytesToWasm(args) + packed := gonextDataDBRead(qp, ql, ap, al) + return unpackHostResult(packed) +} + +func hostCallDBWrite(query, args []byte) ([]byte, int32) { + qp, ql := bytesToWasm(query) + ap, al := bytesToWasm(args) + packed := gonextDataDBWrite(qp, ql, ap, al) + // db.write packs the affected-row count into the low 32 bits + // when ptr=0. unpackHostResult interprets negative length as + // status; non-negative length is the count — which we surface + // as the status value to keep the host_methods.go shape uniform. + return unpackHostResult(packed) +} + +func hostCallKVGet(key []byte) ([]byte, int32) { + kp, kl := bytesToWasm(key) + packed := gonextDataKVGet(kp, kl) + return unpackHostResult(packed) +} + +func hostCallKVSet(key, val []byte) ([]byte, int32) { + kp, kl := bytesToWasm(key) + vp, vl := bytesToWasm(val) + packed := gonextDataKVSet(kp, kl, vp, vl) + return unpackHostResult(packed) +} + +func hostCallKVDel(key []byte) ([]byte, int32) { + kp, kl := bytesToWasm(key) + packed := gonextDataKVDel(kp, kl) + return unpackHostResult(packed) +} + +func hostCallKVIncr(key []byte, delta int64) ([]byte, int32) { + kp, kl := bytesToWasm(key) + packed := gonextDataKVIncr(kp, kl, delta) + return unpackHostResult(packed) +} + +func hostCallCacheInvalidate(tags []byte) ([]byte, int32) { + tp, tl := bytesToWasm(tags) + packed := gonextDataCacheInvalidate(tp, tl) + return unpackHostResult(packed) +} + +func hostCallSecretsGet(key []byte) ([]byte, int32) { + kp, kl := bytesToWasm(key) + packed := envSecretsGet(kp, kl) + return unpackHostResult(packed) +} + +func hostCallAuditEmit(payload []byte) ([]byte, int32) { + pp, pl := bytesToWasm(payload) + status := envAuditEmit(pp, pl) + return nil, status +} + +func hostCallCronRegister(payload []byte) ([]byte, int32) { + pp, pl := bytesToWasm(payload) + status := envCronRegister(pp, pl) + return nil, status +} + +func hostCallMetricObserve(name []byte, value float64, tags []byte) int32 { + np, nl := bytesToWasm(name) + tp, tl := bytesToWasm(tags) + return envMetricObserve(np, nl, value, tp, tl) +} + +func hostCallEventEmit(name, data []byte) int32 { + np, nl := bytesToWasm(name) + dp, dl := bytesToWasm(data) + return envEventEmit(np, nl, dp, dl) +} + +func hostCallSpanEvent(name, attrs []byte) int32 { + np, nl := bytesToWasm(name) + ap, al := bytesToWasm(attrs) + return envSpanEvent(np, nl, ap, al) +} + +func hostCallI18nTranslate(key, locale []byte) ([]byte, int32) { + kp, kl := bytesToWasm(key) + lp, ll := bytesToWasm(locale) + packed := envI18nTranslate(kp, kl, lp, ll) + return unpackHostResult(packed) +} + +func hostCallLog(level int32, msg []byte) { + mp, ml := bytesToWasm(msg) + envLog(level, mp, ml) +} + +func hostCallTimeMs() int64 { + return envTimeMs() +} + +// ============================================================================ +// bytesToWasm prepares a Go byte slice for transmission to the host. +// +// Both the wasm linear memory model and TinyGo's GC complicate this: +// +// - The host reads bytes via mod.Memory().Read(ptr, len). Those +// bytes MUST be in linear memory, not in some hypothetical Go +// heap. +// +// - TinyGo's GC may relocate live allocations. We can't pass a +// pointer to a stack-allocated slice and expect it to survive the +// host call. +// +// The safe path is: copy the bytes into a fresh guestAlloc'd buffer +// and pass that pointer. The buffer is retained by our allocator +// (allocBufs) so the GC keeps it alive across the call. +// +// nil/empty input returns (0, 0) — the host treats that as "no +// payload" uniformly. +// ============================================================================ + +func bytesToWasm(b []byte) (uint32, uint32) { + if len(b) == 0 { + return 0, 0 + } + ptr := writeAlloc(b) + if ptr == 0 { + return 0, 0 + } + return ptr, uint32(len(b)) +} + +// ============================================================================ +// Hook entry point — exported as gn_handle_hook. +// +// The host writes (name, payload) into our memory via gn_alloc BEFORE +// invoking gn_handle_hook. We read the bytes back, dispatch through +// the registry, marshal the response, and pack (resultPtr, len) into +// the i64 return. +// ============================================================================ + +//go:wasmexport gn_handle_hook +func gn_handle_hook(namePtr, nameLen, payloadPtr, payloadLen uint32) uint64 { + name := string(readGuest(namePtr, nameLen)) + payload := readGuest(payloadPtr, payloadLen) + + result, status := DispatchHook(name, payload) + if status != StatusOK { + return PackResult(0, int32(status)) + } + if len(result) == 0 { + return PackResult(0, int32(StatusOK)) + } + ptr := writeAlloc(result) + if ptr == 0 { + return PackResult(0, int32(StatusOutOfMemory)) + } + return PackResult(ptr, int32(len(result))) +} diff --git a/packages/go/sdk/manifest.go b/packages/go/sdk/manifest.go new file mode 100644 index 00000000..71f5dec0 --- /dev/null +++ b/packages/go/sdk/manifest.go @@ -0,0 +1,255 @@ +package sdk + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Manifest is the plugin-author-facing mirror of the gonext.io/v1 +// manifest.json schema. It exists in the SDK so a plugin author can +// build a manifest programmatically (via NewManifest().With...().Build()) +// rather than hand-rolling the JSON. +// +// The shape is intentionally a subset of the host's +// plugins/manifest.Manifest: only the fields a plugin author needs at +// build time. The Validate-able JSON Schema lives in the host package; +// the SDK's Build() emits JSON that passes that schema. +// +// We do not depend on the host manifest package because TinyGo can't +// compile its transitive imports (jsonschema, etc). The "single source +// of truth" property is preserved by the manifest-golden test in the +// host package: it includes a manifest-emitted-by-SDK fixture and +// validates it against the schema in CI. +type Manifest struct { + APIVersion string `json:"apiVersion"` + Name string `json:"name"` + Version string `json:"version"` + Entry string `json:"entry,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Hooks *ManifestHooks `json:"hooks,omitempty"` + Jobs []string `json:"jobs,omitempty"` + Requires *ManifestRequires `json:"requires,omitempty"` + Depends []ManifestDepend `json:"depends,omitempty"` + Storage *ManifestStorage `json:"storage,omitempty"` +} + +// ManifestHooks is the actions/filters split. Both arrays optional; +// an empty object is legal (but pointless). +type ManifestHooks struct { + Actions []string `json:"actions,omitempty"` + Filters []string `json:"filters,omitempty"` +} + +// ManifestRequires carries compatibility ranges. Host is required if +// the object is present. +type ManifestRequires struct { + Host string `json:"host"` +} + +// ManifestDepend is one entry of the depends[] array — another plugin +// slug pinned to a semver range. +type ManifestDepend struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ManifestStorage is the persistent-storage budget bag. KV is the only +// surface in v1. +type ManifestStorage struct { + KV *ManifestKVStorage `json:"kv,omitempty"` +} + +// ManifestKVStorage carries the per-plugin KV namespace budget. Both +// fields are pointers so omitting them is distinguishable from setting +// them to zero (which means "must hold none"). +type ManifestKVStorage struct { + MaxBytes *int64 `json:"max_bytes,omitempty"` + MaxKeys *int `json:"max_keys,omitempty"` +} + +// ManifestBuilder is a fluent builder for Manifest. The pattern lets +// plugin authors write: +// +// manifest := sdk.NewManifest("hello", "0.1.0"). +// WithCapability("kv.write"). +// WithCapability("audit.emit"). +// WithAction("posts.publish"). +// WithFilter("the_content"). +// Build() +// +// Build returns a Manifest pre-populated with apiVersion/entry defaults +// and validates the minimum invariants (non-empty name/version, valid +// slug, no dupes). MustBuild is the panic-on-error variant. +type ManifestBuilder struct { + m Manifest + err error +} + +// NewManifest starts a builder. apiVersion defaults to "gonext.io/v1" +// and entry defaults to "plugin.wasm" — matching the convention every +// example in docs/02-plugin-system.md uses. Override either via +// WithAPIVersion / WithEntry. +func NewManifest(name, version string) *ManifestBuilder { + b := &ManifestBuilder{ + m: Manifest{ + APIVersion: "gonext.io/v1", + Name: name, + Version: version, + Entry: "plugin.wasm", + }, + } + if strings.TrimSpace(name) == "" { + b.err = errors.New("manifest: name is required") + } + if strings.TrimSpace(version) == "" { + b.err = errors.New("manifest: version is required") + } + return b +} + +// WithAPIVersion overrides the apiVersion field. The current v1 host +// accepts only "gonext.io/v1"; pass anything else and the host will +// reject the bundle at install time. +func (b *ManifestBuilder) WithAPIVersion(v string) *ManifestBuilder { + b.m.APIVersion = v + return b +} + +// WithEntry overrides the wasm entry filename. Defaults to +// "plugin.wasm" — matching the build target every Makefile in the +// template directory writes. +func (b *ManifestBuilder) WithEntry(name string) *ManifestBuilder { + b.m.Entry = name + return b +} + +// WithCapability appends one capability slug to the request list. Dupes +// are skipped — capabilities are a set, not a multiset. The host +// enforces grant policy at activation; the SDK only declares intent. +// +// Common values: posts.read, posts.write, http.fetch, db.read, db.write, +// kv.read, kv.write, cache.invalidate, audit.emit, cron.register, +// secrets.read. +func (b *ManifestBuilder) WithCapability(name string) *ManifestBuilder { + for _, existing := range b.m.Capabilities { + if existing == name { + return b + } + } + b.m.Capabilities = append(b.m.Capabilities, name) + return b +} + +// WithCapabilities appends multiple capabilities; same semantics as +// repeated WithCapability calls. +func (b *ManifestBuilder) WithCapabilities(names ...string) *ManifestBuilder { + for _, n := range names { + b.WithCapability(n) + } + return b +} + +// WithAction registers an action hook subscription. Dupes are skipped. +// The handler MUST be registered via RegisterAction at runtime; an +// action declared here but not registered surfaces as +// ResultStatusUnknownHook when the host invokes it. +func (b *ManifestBuilder) WithAction(name string) *ManifestBuilder { + if b.m.Hooks == nil { + b.m.Hooks = &ManifestHooks{} + } + for _, existing := range b.m.Hooks.Actions { + if existing == name { + return b + } + } + b.m.Hooks.Actions = append(b.m.Hooks.Actions, name) + return b +} + +// WithFilter registers a filter hook subscription. Same dupe semantics +// as WithAction. +func (b *ManifestBuilder) WithFilter(name string) *ManifestBuilder { + if b.m.Hooks == nil { + b.m.Hooks = &ManifestHooks{} + } + for _, existing := range b.m.Hooks.Filters { + if existing == name { + return b + } + } + b.m.Hooks.Filters = append(b.m.Hooks.Filters, name) + return b +} + +// WithJob registers a background job id. The host's scheduler resolves +// it against its task registry at activation. +func (b *ManifestBuilder) WithJob(id string) *ManifestBuilder { + for _, existing := range b.m.Jobs { + if existing == id { + return b + } + } + b.m.Jobs = append(b.m.Jobs, id) + return b +} + +// WithHostRequirement sets the requires.host semver range. +func (b *ManifestBuilder) WithHostRequirement(rng string) *ManifestBuilder { + b.m.Requires = &ManifestRequires{Host: rng} + return b +} + +// WithDependency adds an inter-plugin dependency entry. +func (b *ManifestBuilder) WithDependency(name, versionRange string) *ManifestBuilder { + b.m.Depends = append(b.m.Depends, ManifestDepend{Name: name, Version: versionRange}) + return b +} + +// WithKVStorage declares persistent-storage budgets for the plugin's +// KV namespace. Pass nil for either field to leave it omitted (use +// operator default). Pass a pointer to zero to declare "must hold none" +// — useful for disabling the namespace. +func (b *ManifestBuilder) WithKVStorage(maxBytes *int64, maxKeys *int) *ManifestBuilder { + if b.m.Storage == nil { + b.m.Storage = &ManifestStorage{} + } + b.m.Storage.KV = &ManifestKVStorage{MaxBytes: maxBytes, MaxKeys: maxKeys} + return b +} + +// Build returns the configured Manifest. If the builder accumulated +// an error (empty name/version), Build returns the zero Manifest and +// the error — callers that prefer panic can use MustBuild instead. +// +// Note: this is a SHALLOW build. The full schema validation lives in +// the host's plugins/manifest package; Build only checks invariants +// the builder can guarantee statically (non-empty fields, no dupes). +func (b *ManifestBuilder) Build() (Manifest, error) { + if b.err != nil { + return Manifest{}, b.err + } + return b.m, nil +} + +// MustBuild is the panic-on-error variant. Convenient when a plugin +// builds its manifest at package init time — a misconfigured manifest +// is a programmer error, not a runtime condition. +func (b *ManifestBuilder) MustBuild() Manifest { + m, err := b.Build() + if err != nil { + panic(fmt.Sprintf("sdk.Manifest.MustBuild: %v", err)) + } + return m +} + +// MarshalJSON emits the manifest as the canonical JSON the host +// schema validates. We implement this explicitly (instead of relying +// on the struct tags alone) so the field order matches the host's +// schema example and golden fixtures. +func (m Manifest) MarshalJSON() ([]byte, error) { + // Use an alias to avoid recursing into ourselves. + type alias Manifest + return json.Marshal(alias(m)) +} diff --git a/packages/go/sdk/manifest_test.go b/packages/go/sdk/manifest_test.go new file mode 100644 index 00000000..5ffd3d6a --- /dev/null +++ b/packages/go/sdk/manifest_test.go @@ -0,0 +1,197 @@ +package sdk + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestManifestBuilderDefaults(t *testing.T) { + m, err := NewManifest("hello", "0.1.0").Build() + if err != nil { + t.Fatalf("build: %v", err) + } + if m.APIVersion != "gonext.io/v1" { + t.Errorf("apiVersion: got %q, want gonext.io/v1", m.APIVersion) + } + if m.Entry != "plugin.wasm" { + t.Errorf("entry: got %q, want plugin.wasm", m.Entry) + } + if m.Name != "hello" { + t.Errorf("name: got %q, want hello", m.Name) + } + if m.Version != "0.1.0" { + t.Errorf("version: got %q, want 0.1.0", m.Version) + } +} + +func TestManifestBuilderEmptyNameRejected(t *testing.T) { + _, err := NewManifest("", "1.0.0").Build() + if err == nil { + t.Fatal("expected error for empty name") + } + if !strings.Contains(err.Error(), "name is required") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestManifestBuilderEmptyVersionRejected(t *testing.T) { + _, err := NewManifest("hello", "").Build() + if err == nil { + t.Fatal("expected error for empty version") + } + if !strings.Contains(err.Error(), "version is required") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestManifestBuilderCapabilitiesDedup(t *testing.T) { + m, err := NewManifest("hello", "0.1.0"). + WithCapability("kv.write"). + WithCapability("audit.emit"). + WithCapability("kv.write"). // dupe + Build() + if err != nil { + t.Fatalf("build: %v", err) + } + if len(m.Capabilities) != 2 { + t.Errorf("expected 2 caps after dedup, got %d: %v", len(m.Capabilities), m.Capabilities) + } +} + +func TestManifestBuilderCapabilitiesVariadic(t *testing.T) { + m, err := NewManifest("hello", "0.1.0"). + WithCapabilities("kv.write", "audit.emit", "http.fetch"). + Build() + if err != nil { + t.Fatalf("build: %v", err) + } + if len(m.Capabilities) != 3 { + t.Errorf("expected 3 caps, got %d", len(m.Capabilities)) + } +} + +func TestManifestBuilderHooks(t *testing.T) { + m, err := NewManifest("hello", "0.1.0"). + WithAction("posts.publish"). + WithAction("posts.publish"). // dupe + WithFilter("the_content"). + Build() + if err != nil { + t.Fatalf("build: %v", err) + } + if m.Hooks == nil { + t.Fatal("expected non-nil hooks") + } + if len(m.Hooks.Actions) != 1 || m.Hooks.Actions[0] != "posts.publish" { + t.Errorf("actions: %v", m.Hooks.Actions) + } + if len(m.Hooks.Filters) != 1 || m.Hooks.Filters[0] != "the_content" { + t.Errorf("filters: %v", m.Hooks.Filters) + } +} + +func TestManifestBuilderJSONShape(t *testing.T) { + m := NewManifest("gonext-hello", "0.1.0"). + WithCapability("kv.write"). + WithCapability("audit.emit"). + WithAction("posts.publish"). + WithFilter("the_content"). + WithHostRequirement(">=0.1.0"). + MustBuild() + buf, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // We don't validate against the host schema here (that test + // lives in the host package), but we do check the fields we + // expect are present. + s := string(buf) + required := []string{ + `"apiVersion":"gonext.io/v1"`, + `"name":"gonext-hello"`, + `"version":"0.1.0"`, + `"entry":"plugin.wasm"`, + `"kv.write"`, + `"audit.emit"`, + `"posts.publish"`, + `"the_content"`, + // json.Marshal HTML-escapes `>` as a unicode sequence in + // strings; we check the escaped form because that's what's + // on the wire. The host's decoder accepts both forms. + "\"host\":\"\\u003e=0.1.0\"", + } + for _, want := range required { + if !strings.Contains(s, want) { + t.Errorf("missing %q in output: %s", want, s) + } + } +} + +func TestManifestBuilderWithEntry(t *testing.T) { + m, err := NewManifest("hello", "0.1.0").WithEntry("custom.wasm").Build() + if err != nil { + t.Fatalf("build: %v", err) + } + if m.Entry != "custom.wasm" { + t.Errorf("entry: got %q, want custom.wasm", m.Entry) + } +} + +func TestManifestBuilderWithDependency(t *testing.T) { + m, err := NewManifest("hello", "0.1.0"). + WithDependency("gn-seo", "^0.1.0"). + Build() + if err != nil { + t.Fatalf("build: %v", err) + } + if len(m.Depends) != 1 { + t.Fatalf("depends length: got %d, want 1", len(m.Depends)) + } + if m.Depends[0].Name != "gn-seo" || m.Depends[0].Version != "^0.1.0" { + t.Errorf("depends entry: %+v", m.Depends[0]) + } +} + +func TestManifestBuilderWithJob(t *testing.T) { + m, err := NewManifest("hello", "0.1.0"). + WithJob("recompute-scores"). + WithJob("recompute-scores"). // dupe + WithJob("notify-admins"). + Build() + if err != nil { + t.Fatalf("build: %v", err) + } + if len(m.Jobs) != 2 { + t.Errorf("jobs after dedup: %v", m.Jobs) + } +} + +func TestManifestBuilderWithKVStorage(t *testing.T) { + maxBytes := int64(1024 * 1024) + maxKeys := 100 + m, err := NewManifest("hello", "0.1.0"). + WithKVStorage(&maxBytes, &maxKeys). + Build() + if err != nil { + t.Fatalf("build: %v", err) + } + if m.Storage == nil || m.Storage.KV == nil { + t.Fatal("storage.kv should be set") + } + if *m.Storage.KV.MaxBytes != maxBytes { + t.Errorf("max_bytes: %d", *m.Storage.KV.MaxBytes) + } + if *m.Storage.KV.MaxKeys != maxKeys { + t.Errorf("max_keys: %d", *m.Storage.KV.MaxKeys) + } +} + +func TestMustBuildPanicsOnError(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic") + } + }() + NewManifest("", "0.1.0").MustBuild() +} diff --git a/packages/go/sdk/tinygo_integration_test.go b/packages/go/sdk/tinygo_integration_test.go new file mode 100644 index 00000000..c313b317 --- /dev/null +++ b/packages/go/sdk/tinygo_integration_test.go @@ -0,0 +1,79 @@ +package sdk + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// tinygo_integration_test.go is the build-and-verify gate for the +// SDK's wasm-target output. It only runs when `tinygo` is on PATH — +// otherwise the test skips, so CI without the toolchain still +// passes. +// +// What we verify: +// +// - The sdk-go-hello example compiles with TinyGo against the SDK +// under `-target=wasi`. The plugin's main.go pulls in the SDK, +// which exercises every host_wasm.go //go:wasmimport stanza — +// so a mistyped import or a broken `//go:wasmexport` annotation +// surfaces here. +// +// - The output is non-empty and exports the required ABI symbols +// (gn_handle_hook, gn_alloc, gn_free). We grep for the symbol +// names; a real CI would run `wasm-tools inspect` but grep is +// the lowest-dependency option. +// +// This test is the "does the SDK actually compile to wasm" guard. +// The handler-logic tests in examples/plugins/sdk-go-hello/main_test.go +// cover the Go-level behaviour; this test covers the build product. + +func TestTinyGoBuildExample(t *testing.T) { + if _, err := exec.LookPath("tinygo"); err != nil { + t.Skip("tinygo not on PATH; skipping wasm-build integration test") + } + + // Walk up to the repo root, then over to the example. The SDK + // lives at packages/go/sdk; the example at + // examples/plugins/sdk-go-hello. + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + exampleDir := filepath.Join(wd, "..", "..", "..", "examples", "plugins", "sdk-go-hello") + if _, err := os.Stat(filepath.Join(exampleDir, "main.go")); err != nil { + t.Fatalf("example not found at %s: %v", exampleDir, err) + } + + out := filepath.Join(t.TempDir(), "plugin.wasm") + cmd := exec.Command("tinygo", "build", "-target=wasi", "-no-debug", "-o", out, ".") + cmd.Dir = exampleDir + cmd.Env = append(os.Environ(), "GOFLAGS=") // clear any GOFLAGS that might pick up -mod=vendor + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("tinygo build failed: %v\n%s", err, output) + } + + st, err := os.Stat(out) + if err != nil { + t.Fatalf("stat output: %v", err) + } + if st.Size() == 0 { + t.Fatal("tinygo produced an empty wasm") + } + + // Verify the required exports are present in the binary. + // Symbol names appear verbatim in the wasm export section — + // a grep is the cheapest reliable check. + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read output: %v", err) + } + required := []string{"gn_handle_hook", "gn_alloc", "gn_free"} + for _, sym := range required { + if !strings.Contains(string(data), sym) { + t.Errorf("missing required export %q in wasm", sym) + } + } +}