diff --git a/go.mod b/go.mod
index 8b5fdaadd..c5b4191d0 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
github.com/djherbis/stream v1.4.0
github.com/djherbis/times v1.6.0
github.com/dustin/go-humanize v1.0.1
+ github.com/extism/go-pdk v1.1.3
github.com/extism/go-sdk v1.7.1
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.2.3
diff --git a/go.sum b/go.sum
index d138806ef..8af463fbf 100644
--- a/go.sum
+++ b/go.sum
@@ -57,6 +57,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
+github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
+github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
diff --git a/plugins/cmd/hostgen/README.md b/plugins/cmd/hostgen/README.md
index 1d47b6860..240deda5d 100644
--- a/plugins/cmd/hostgen/README.md
+++ b/plugins/cmd/hostgen/README.md
@@ -5,18 +5,22 @@ A code generator for Navidrome's plugin host functions. It reads Go interface de
## Usage
```bash
-hostgen -input
-output -package [-v] [-dry-run]
+hostgen -input -output -package [-v] [-dry-run] [-host-only] [-plugin-only]
```
### Flags
-| Flag | Description | Default |
-|------------|----------------------------------------------------------------|----------|
-| `-input` | Directory containing Go source files with annotated interfaces | Required |
-| `-output` | Directory where generated files will be written | Required |
-| `-package` | Package name for generated files | Required |
-| `-v` | Verbose output | `false` |
-| `-dry-run` | Parse and validate without writing files | `false` |
+| Flag | Description | Default |
+|----------------|----------------------------------------------------------------|----------|
+| `-input` | Directory containing Go source files with annotated interfaces | Required |
+| `-output` | Directory where generated files will be written | Required |
+| `-package` | Package name for generated files | Required |
+| `-v` | Verbose output | `false` |
+| `-dry-run` | Parse and validate without writing files | `false` |
+| `-host-only` | Generate only host-side wrapper code | `false` |
+| `-plugin-only` | Generate only plugin/client-side wrapper code | `false` |
+
+By default, both host and plugin code are generated. Use `-host-only` or `-plugin-only` to generate only one type.
### Example
@@ -162,7 +166,9 @@ type ServiceSearchResponse struct {
## Output Files
-Generated files are named `_gen.go` (lowercase). Each file includes:
+### Host Code (Navidrome-side)
+
+Generated files are named `_gen.go` (lowercase) and placed in the output directory. Each file includes:
- `// Code generated by hostgen. DO NOT EDIT.` header
- Required imports (`context`, `encoding/json`, `extism`)
@@ -171,6 +177,25 @@ Generated files are named `_gen.go` (lowercase). Each file includes
- Host function wrappers
- Helper functions (`writeResponse`, `writeErrorResponse`)
+### Plugin/Client Code (TinyGo WASM)
+
+Generated files are named `nd_host_.go` (lowercase) and placed in the `go/` subdirectory of the output directory. These files are intended for use in Navidrome plugins built with TinyGo. Each file includes:
+
+- `// Code generated by hostgen. DO NOT EDIT.` header
+- Required imports (`encoding/json`, `errors`, `github.com/extism/go-pdk`)
+- `//go:wasmimport` declarations for each host function
+- Response struct types
+- Wrapper functions that handle memory allocation and JSON parsing
+
+### Example Output Structure
+
+```
+output/
+├── subsonicapi_gen.go # Host-side code (for Navidrome)
+└── go/
+ └── nd_host_subsonicapi.go # Plugin-side code (for TinyGo plugins)
+```
+
## Troubleshooting
### Annotations Not Detected
diff --git a/plugins/cmd/hostgen/integration_test.go b/plugins/cmd/hostgen/integration_test.go
index 6f7ef64e3..6385ee48f 100644
--- a/plugins/cmd/hostgen/integration_test.go
+++ b/plugins/cmd/hostgen/integration_test.go
@@ -150,69 +150,146 @@ type ServiceB interface {
Expect(filepath.Join(outputDir, "servicea_gen.go")).To(BeAnExistingFile())
Expect(filepath.Join(outputDir, "serviceb_gen.go")).To(BeAnExistingFile())
})
+
+ It("generates only host code with -host-only flag", func() {
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-host-only")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ Expect(filepath.Join(outputDir, "test_gen.go")).To(BeAnExistingFile())
+ Expect(filepath.Join(outputDir, "go")).ToNot(BeADirectory())
+ })
+
+ It("generates only client code with -plugin-only flag", func() {
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "main", "-plugin-only")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ // Host code should not exist in output root
+ entries, err := os.ReadDir(outputDir)
+ Expect(err).ToNot(HaveOccurred())
+
+ var genFiles []string
+ for _, e := range entries {
+ if e.Name() != "go" {
+ genFiles = append(genFiles, e.Name())
+ }
+ }
+ Expect(genFiles).To(BeEmpty(), "Expected no host code files, found: %v", genFiles)
+
+ // Client code should exist in go/ subdirectory
+ Expect(filepath.Join(outputDir, "go")).To(BeADirectory())
+ Expect(filepath.Join(outputDir, "go", "nd_host_test.go")).To(BeAnExistingFile())
+ })
+
+ It("generates both host and client code by default", func() {
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ // Host code in output root
+ Expect(filepath.Join(outputDir, "test_gen.go")).To(BeAnExistingFile())
+
+ // Client code in go/ subdirectory
+ Expect(filepath.Join(outputDir, "go")).To(BeADirectory())
+ Expect(filepath.Join(outputDir, "go", "nd_host_test.go")).To(BeAnExistingFile())
+ })
+
+ It("rejects using both -host-only and -plugin-only together", func() {
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-host-only", "-plugin-only")
+ output, err := cmd.CombinedOutput()
+ Expect(err).To(HaveOccurred())
+ Expect(string(output)).To(ContainSubstring("-host-only and -plugin-only cannot be used together"))
+ })
})
Describe("code generation", func() {
- DescribeTable("generates correct output",
- func(serviceFile, expectedFile string) {
+ DescribeTable("generates correct host and client output",
+ func(serviceFile, hostExpectedFile, clientExpectedFile string) {
serviceCode := readTestdata(serviceFile)
- expectedCode := readTestdata(expectedFile)
+ hostExpected := readTestdata(hostExpectedFile)
+ clientExpected := readTestdata(clientExpectedFile)
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+ // Generate both host and client code in one run
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+ // Verify host code
entries, err := os.ReadDir(outputDir)
Expect(err).ToNot(HaveOccurred())
- Expect(entries).To(HaveLen(1), "Expected exactly one generated file")
- actual, err := os.ReadFile(filepath.Join(outputDir, entries[0].Name()))
+ var hostFiles []string
+ for _, e := range entries {
+ if e.Name() != "go" && !e.IsDir() {
+ hostFiles = append(hostFiles, e.Name())
+ }
+ }
+ Expect(hostFiles).To(HaveLen(1), "Expected exactly one host file, got: %v", hostFiles)
+
+ hostActual, err := os.ReadFile(filepath.Join(outputDir, hostFiles[0]))
Expect(err).ToNot(HaveOccurred())
- // Format both for comparison
- formattedActual, err := format.Source(actual)
- Expect(err).ToNot(HaveOccurred(), "Generated code is not valid Go:\n%s", actual)
+ formattedHostActual, err := format.Source(hostActual)
+ Expect(err).ToNot(HaveOccurred(), "Generated host code is not valid Go:\n%s", hostActual)
- formattedExpected, err := format.Source([]byte(expectedCode))
- Expect(err).ToNot(HaveOccurred(), "Expected code is not valid Go")
+ formattedHostExpected, err := format.Source([]byte(hostExpected))
+ Expect(err).ToNot(HaveOccurred(), "Expected host code is not valid Go")
- Expect(string(formattedActual)).To(Equal(string(formattedExpected)))
+ Expect(string(formattedHostActual)).To(Equal(string(formattedHostExpected)), "Host code mismatch")
+
+ // Verify client code
+ goDir := filepath.Join(outputDir, "go")
+ clientEntries, err := os.ReadDir(goDir)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(clientEntries).To(HaveLen(1), "Expected exactly one client file")
+
+ clientActual, err := os.ReadFile(filepath.Join(goDir, clientEntries[0].Name()))
+ Expect(err).ToNot(HaveOccurred())
+
+ formattedClientActual, err := format.Source(clientActual)
+ Expect(err).ToNot(HaveOccurred(), "Generated client code is not valid Go:\n%s", clientActual)
+
+ formattedClientExpected, err := format.Source([]byte(clientExpected))
+ Expect(err).ToNot(HaveOccurred(), "Expected client code is not valid Go")
+
+ Expect(string(formattedClientActual)).To(Equal(string(formattedClientExpected)), "Client code mismatch")
},
- Entry("simple string params - no request type needed",
- "echo_service.go", "echo_expected.go"),
+ Entry("simple string params",
+ "echo_service.go", "echo_expected.go", "echo_client_expected.go"),
- Entry("multiple simple params",
- "math_service.go", "math_expected.go"),
+ Entry("multiple simple params (int32)",
+ "math_service.go", "math_expected.go", "math_client_expected.go"),
Entry("struct param with request type",
- "store_service.go", "store_expected.go"),
+ "store_service.go", "store_expected.go", "store_client_expected.go"),
Entry("mixed simple and complex params",
- "list_service.go", "list_expected.go"),
+ "list_service.go", "list_expected.go", "list_client_expected.go"),
Entry("method without error",
- "counter_service.go", "counter_expected.go"),
+ "counter_service.go", "counter_expected.go", "counter_client_expected.go"),
Entry("no params, error only",
- "ping_service.go", "ping_expected.go"),
+ "ping_service.go", "ping_expected.go", "ping_client_expected.go"),
Entry("map and interface types",
- "meta_service.go", "meta_expected.go"),
+ "meta_service.go", "meta_expected.go", "meta_client_expected.go"),
Entry("pointer types",
- "users_service.go", "users_expected.go"),
+ "users_service.go", "users_expected.go", "users_client_expected.go"),
Entry("multiple returns",
- "search_service.go", "search_expected.go"),
+ "search_service.go", "search_expected.go", "search_client_expected.go"),
Entry("bytes",
- "codec_service.go", "codec_expected.go"),
+ "codec_service.go", "codec_expected.go", "codec_client_expected.go"),
)
- It("generates compilable code for comprehensive service", func() {
+ It("generates compilable host code for comprehensive service", func() {
serviceCode := readTestdata("comprehensive_service.go")
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
@@ -221,8 +298,8 @@ type ServiceB interface {
goMod := "module testpkg\n\ngo 1.23\n\nrequire github.com/extism/go-sdk v1.7.1\n"
Expect(os.WriteFile(filepath.Join(testDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
- // Generate
- cmd := exec.Command(hostgenBin, "-input", testDir, "-output", testDir, "-package", "testpkg")
+ // Generate host code only
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", testDir, "-package", "testpkg", "-host-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
@@ -238,6 +315,89 @@ type ServiceB interface {
buildOutput, err := buildCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Build failed: %s", buildOutput)
})
+
+ It("generates compilable client code for comprehensive service", func() {
+ serviceCode := readTestdata("comprehensive_service.go")
+
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ // Generate client code only to a separate client directory
+ clientDir := filepath.Join(outputDir, "client")
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", clientDir, "-package", "main", "-plugin-only")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
+
+ // Read generated client code
+ goDir := filepath.Join(clientDir, "go")
+ entries, err := os.ReadDir(goDir)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entries).To(HaveLen(1), "Expected exactly one generated client file")
+
+ content, err := os.ReadFile(filepath.Join(goDir, entries[0].Name()))
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify key expected content first
+ contentStr := string(content)
+ // Should have wasmimport declarations for all methods
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_simpleparams"))
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_structparam"))
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noerror"))
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparams"))
+ Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparamsnoreturns"))
+
+ // Should have response types for methods with complex returns
+ Expect(contentStr).To(ContainSubstring("type ComprehensiveSimpleParamsResponse struct"))
+ Expect(contentStr).To(ContainSubstring("type ComprehensiveMultipleReturnsResponse struct"))
+
+ // Should have wrapper functions
+ Expect(contentStr).To(ContainSubstring("func ComprehensiveSimpleParams("))
+ Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParams()"))
+ Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParamsNoReturns()"))
+
+ // Move generated file to clientDir root for compilation
+ Expect(os.Rename(filepath.Join(goDir, entries[0].Name()), filepath.Join(clientDir, "nd_host.go"))).To(Succeed())
+
+ // Create go.mod for client code
+ goMod := "module main\n\ngo 1.23\n\nrequire github.com/extism/go-pdk v1.1.1\n"
+ Expect(os.WriteFile(filepath.Join(clientDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
+
+ // Add a simple main function for the plugin
+ mainGo := `package main
+
+func main() {}
+`
+ Expect(os.WriteFile(filepath.Join(clientDir, "main.go"), []byte(mainGo), 0600)).To(Succeed())
+
+ // Add type definitions needed by the generated code
+ typesGo := `package main
+
+type User2 struct {
+ ID string
+ Name string
+}
+
+type Filter2 struct {
+ Active bool
+}
+`
+ Expect(os.WriteFile(filepath.Join(clientDir, "types.go"), []byte(typesGo), 0600)).To(Succeed())
+
+ // Tidy dependencies
+ goTidyCmd := exec.Command("go", "mod", "tidy")
+ goTidyCmd.Dir = clientDir
+ goTidyOutput, err := goTidyCmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "go mod tidy failed: %s", goTidyOutput)
+
+ // Build as WASM plugin - this validates the client code compiles correctly
+ buildCmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", "plugin.wasm", ".")
+ buildCmd.Dir = clientDir
+ buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
+ buildOutput, err := buildCmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "WASM build failed: %s", buildOutput)
+
+ // Verify .wasm file was created
+ Expect(filepath.Join(clientDir, "plugin.wasm")).To(BeAnExistingFile())
+ })
})
})
diff --git a/plugins/cmd/hostgen/internal/generator.go b/plugins/cmd/hostgen/internal/generator.go
index 8e7eeb06e..ee9533cda 100644
--- a/plugins/cmd/hostgen/internal/generator.go
+++ b/plugins/cmd/hostgen/internal/generator.go
@@ -2,14 +2,18 @@ package internal
import (
"bytes"
+ "embed"
"fmt"
"strings"
"text/template"
)
-// GenerateService generates the host function wrapper code for a service.
-func GenerateService(svc Service, pkgName string) ([]byte, error) {
- tmpl, err := template.New("service").Funcs(template.FuncMap{
+//go:embed templates/*.tmpl
+var templatesFS embed.FS
+
+// hostFuncMap returns the template functions for host code generation.
+func hostFuncMap(svc Service) template.FuncMap {
+ return template.FuncMap{
"lower": strings.ToLower,
"title": strings.Title,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
@@ -27,7 +31,39 @@ func GenerateService(svc Service, pkgName string) ([]byte, error) {
"readParam": generateReadParam,
"writeReturn": generateWriteReturn,
"encodeReturn": generateEncodeReturn,
- }).Parse(serviceTemplate)
+ }
+}
+
+// clientFuncMap returns the template functions for client code generation.
+func clientFuncMap(svc Service) template.FuncMap {
+ return template.FuncMap{
+ "lower": strings.ToLower,
+ "title": strings.Title,
+ "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
+ "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
+ "isSimple": IsSimpleType,
+ "isString": IsStringType,
+ "isBytes": IsBytesType,
+ "needsJSON": NeedsJSON,
+ "needsRespType": func(m Method) bool { return m.NeedsResponseType() },
+ "isErrorOnly": func(m Method) bool { return m.IsErrorOnly() },
+ "wasmParamType": wasmParamType,
+ "wasmReturnType": wasmReturnType,
+ "wrapperReturnType": func(m Method, svcName string) string { return wrapperReturnType(m, svcName) },
+ "clientCallArg": clientCallArg,
+ "decodeResult": decodeResult,
+ "formatDoc": formatDoc,
+ }
+}
+
+// GenerateHost generates the host function wrapper code for a service.
+func GenerateHost(svc Service, pkgName string) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading host template: %w", err)
+ }
+
+ tmpl, err := template.New("host").Funcs(hostFuncMap(svc)).Parse(string(tmplContent))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
@@ -48,10 +84,43 @@ func GenerateService(svc Service, pkgName string) ([]byte, error) {
return buf.Bytes(), nil
}
+// GenerateService generates the host function wrapper code for a service.
+// Deprecated: Use GenerateHost instead.
+func GenerateService(svc Service, pkgName string) ([]byte, error) {
+ return GenerateHost(svc, pkgName)
+}
+
+// GenerateClientGo generates client wrapper code for plugins to call host functions.
+func GenerateClientGo(svc Service) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/client_go.go.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading client template: %w", err)
+ }
+
+ tmpl, err := template.New("client").Funcs(clientFuncMap(svc)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := templateData{
+ Service: svc,
+ NeedsJSON: serviceClientNeedsJSON(svc),
+ NeedsErrors: serviceClientNeedsErrors(svc),
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
type templateData struct {
Package string
Service Service
NeedsJSON bool
+ NeedsErrors bool // Client: needs "errors" import
NeedsWriteHelper bool
NeedsErrorHelper bool
}
@@ -87,6 +156,35 @@ func serviceNeedsWriteHelper(svc Service) bool {
return false
}
+// serviceClientNeedsJSON returns true if any method needs JSON encoding in client code.
+// This is true if any method has a response type (complex returns) or if any param/return needs JSON.
+func serviceClientNeedsJSON(svc Service) bool {
+ for _, m := range svc.Methods {
+ // Response types use JSON for serialization
+ if m.NeedsResponseType() {
+ return true
+ }
+ // Parameters that need JSON marshaling
+ for _, p := range m.Params {
+ if NeedsJSON(p.Type) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// serviceClientNeedsErrors returns true if any method needs the errors package in client code.
+// This is true if any method returns an error.
+func serviceClientNeedsErrors(svc Service) bool {
+ for _, m := range svc.Methods {
+ if m.HasError {
+ return true
+ }
+ }
+ return false
+}
+
// serviceNeedsErrorHelper returns true if any method needs error handling with JSON.
func serviceNeedsErrorHelper(svc Service) bool {
for _, m := range svc.Methods {
@@ -206,165 +304,94 @@ func generateEncodeReturn(p Param, varName string) string {
}
}
-const serviceTemplate = `// Code generated by hostgen. DO NOT EDIT.
+// Client-side helper functions for template
-package {{.Package}}
-
-import (
- "context"
-{{- if .NeedsJSON}}
- "encoding/json"
-{{- end}}
-
- extism "github.com/extism/go-sdk"
-)
-
-{{- /* Generate request/response types only when needed */ -}}
-{{range .Service.Methods}}
-{{- if needsRequestType .}}
-
-// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
-type {{requestType .}} struct {
-{{- range .Params}}
- {{title .Name}} {{.Type}} ` + "`" + `json:"{{.JSONName}}"` + "`" + `
-{{- end}}
+// wasmParamType returns the WASM parameter type for a Go parameter.
+func wasmParamType(p Param) string {
+ if IsSimpleType(p.Type) {
+ return p.Type
+ }
+ // All pointer types (string, []byte, complex) use uint64 offset
+ return "uint64"
}
-{{- end}}
-{{- if needsRespType .}}
-// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
-type {{responseType .}} struct {
-{{- range .Returns}}
- {{title .Name}} {{.Type}} ` + "`" + `json:"{{.JSONName}},omitempty"` + "`" + `
-{{- end}}
- Error string ` + "`" + `json:"error,omitempty"` + "`" + `
+// wasmReturnType returns the WASM return type declaration for a method.
+func wasmReturnType(m Method) string {
+ // Methods with JSON responses or error-only return uint64 (pointer)
+ if m.NeedsResponseType() || m.IsErrorOnly() {
+ return "uint64"
+ }
+ // Simple return types
+ if len(m.Returns) == 1 && IsSimpleType(m.Returns[0].Type) {
+ return m.Returns[0].Type
+ }
+ // No returns or multiple returns - use uint64 for pointer
+ if len(m.Returns) == 0 {
+ return ""
+ }
+ return "uint64"
}
-{{- end}}
-{{end}}
-// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
-// The returned host functions should be added to the plugin's configuration.
-func Register{{.Service.Name}}HostFunctions(service {{.Service.Interface}}) []extism.HostFunction {
- return []extism.HostFunction{
-{{- range .Service.Methods}}
- new{{$.Service.Name}}{{.Name}}HostFunction(service),
-{{- end}}
+// wrapperReturnType returns the Go return type for the wrapper function.
+func wrapperReturnType(m Method, svcName string) string {
+ if m.NeedsResponseType() {
+ return fmt.Sprintf("(*%s%sResponse, error)", svcName, m.Name)
+ }
+ if m.IsErrorOnly() {
+ return "error"
+ }
+ if len(m.Returns) == 1 {
+ return m.Returns[0].Type
+ }
+ return ""
+}
+
+// clientCallArg returns the argument expression for calling the host function.
+func clientCallArg(p Param) string {
+ if IsSimpleType(p.Type) {
+ return p.Name
+ }
+ // Pointer types use .Offset()
+ return p.Name + "Mem.Offset()"
+}
+
+// decodeResult generates code to decode a simple return value.
+func decodeResult(p Param, varName string) string {
+ switch p.Type {
+ case "int32":
+ return fmt.Sprintf("int32(%s)", varName)
+ case "uint32":
+ return fmt.Sprintf("uint32(%s)", varName)
+ case "int64":
+ return fmt.Sprintf("int64(%s)", varName)
+ case "uint64":
+ return varName
+ case "float32":
+ return fmt.Sprintf("math.Float32frombits(uint32(%s))", varName)
+ case "float64":
+ return fmt.Sprintf("math.Float64frombits(%s)", varName)
+ case "bool":
+ return fmt.Sprintf("%s != 0", varName)
+ case "string":
+ // pdk.FindMemory returns a value type, ReadBytes has pointer receiver
+ return fmt.Sprintf("func() string { m := pdk.FindMemory(%s); return string(m.ReadBytes()) }()", varName)
+ case "[]byte":
+ return fmt.Sprintf("func() []byte { m := pdk.FindMemory(%s); return m.ReadBytes() }()", varName)
+ default:
+ return varName
}
}
-{{range .Service.Methods}}
-func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) extism.HostFunction {
- return extism.NewHostFunctionWithStack(
- "{{exportName .}}",
- func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
-{{- if .HasParams}}
-{{- if needsRequestType .}}
- // Read JSON request from plugin memory
- reqBytes, err := p.ReadBytes(stack[0])
- if err != nil {
- {{$.Service.Name | lower}}WriteError(p, stack, err)
- return
- }
- var req {{requestType .}}
- if err := json.Unmarshal(reqBytes, &req); err != nil {
- {{$.Service.Name | lower}}WriteError(p, stack, err)
- return
- }
-{{- else}}
- // Read parameters from stack
-{{- range $i, $p := .Params}}
- {{readParam $p $i}}
-{{- end}}
-{{- end}}
-{{- end}}
-
- // Call the service method
-{{- $m := .}}
-{{- if .HasReturns}}
-{{- if .HasError}}
- {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, err := service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
-{{- else}}
- {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
-{{- end}}
-{{- else if .HasError}}
- err {{if hasErrFromRead .}}={{else}}:={{end}} service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
-{{- else}}
- service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
-{{- end}}
-{{- if .HasError}}
- if err != nil {
-{{- if isErrorOnly .}}
- // Write error string to plugin memory
- if ptr, err := p.WriteString(err.Error()); err == nil {
- stack[0] = ptr
- }
-{{- else if needsRespType .}}
- {{$.Service.Name | lower}}WriteError(p, stack, err)
-{{- end}}
- return
- }
-{{- end}}
-
-{{- if isErrorOnly .}}
- // Write empty string to indicate success
- if ptr, err := p.WriteString(""); err == nil {
- stack[0] = ptr
- }
-{{- else if needsRespType .}}
- // Write JSON response to plugin memory
- resp := {{responseType .}}{
-{{- range .Returns}}
- {{title .Name}}: {{lower .Name}},
-{{- end}}
- }
- {{$.Service.Name | lower}}WriteResponse(p, stack, resp)
-{{- else if .HasReturns}}
- // Write return values to stack
-{{- range $i, $r := .Returns}}
- {{writeReturn $r $i (lower $r.Name)}}
-{{- end}}
-{{- end}}
- },
-{{- if needsRequestType $m}}
- []extism.ValueType{extism.ValueTypePTR},
-{{- else}}
- []extism.ValueType{ {{- range $i, $p := .Params}}{{if $i}}, {{end}}{{valueType $p.Type}}{{end}}{{if not .HasParams}}{{end}} },
-{{- end}}
-{{- if or (needsRespType .) (isErrorOnly .)}}
- []extism.ValueType{extism.ValueTypePTR},
-{{- else}}
- []extism.ValueType{ {{- range $i, $r := .Returns}}{{if $i}}, {{end}}{{valueType $r.Type}}{{end}}{{if not .HasReturns}}{{end}} },
-{{- end}}
- )
-}
-{{end}}
-{{- if .NeedsWriteHelper}}
-
-// {{.Service.Name | lower}}WriteResponse writes a JSON response to plugin memory.
-func {{.Service.Name | lower}}WriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
- respBytes, err := json.Marshal(resp)
- if err != nil {
- {{.Service.Name | lower}}WriteError(p, stack, err)
- return
+// formatDoc formats a documentation string for Go comments.
+// It prefixes each line with "// " and trims trailing whitespace.
+func formatDoc(doc string) string {
+ if doc == "" {
+ return ""
}
- respPtr, err := p.WriteBytes(respBytes)
- if err != nil {
- stack[0] = 0
- return
+ lines := strings.Split(strings.TrimSpace(doc), "\n")
+ var result []string
+ for _, line := range lines {
+ result = append(result, "// "+strings.TrimRight(line, " \t"))
}
- stack[0] = respPtr
+ return strings.Join(result, "\n")
}
-{{- end}}
-{{- if .NeedsErrorHelper}}
-
-// {{.Service.Name | lower}}WriteError writes an error response to plugin memory.
-func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
- errResp := struct {
- Error string ` + "`" + `json:"error"` + "`" + `
- }{Error: err.Error()}
- respBytes, _ := json.Marshal(errResp)
- respPtr, _ := p.WriteBytes(respBytes)
- stack[0] = respPtr
-}
-{{- end}}
-`
diff --git a/plugins/cmd/hostgen/internal/templates/client_go.go.tmpl b/plugins/cmd/hostgen/internal/templates/client_go.go.tmpl
new file mode 100644
index 000000000..8a6d3f7db
--- /dev/null
+++ b/plugins/cmd/hostgen/internal/templates/client_go.go.tmpl
@@ -0,0 +1,139 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the {{.Service.Name}} host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+{{- if .NeedsJSON}}
+ "encoding/json"
+{{- end}}
+{{- if .NeedsErrors}}
+ "errors"
+{{- end}}
+
+ "github.com/extism/go-pdk"
+)
+
+{{- /* Generate wasmimport declarations for each method */ -}}
+{{range .Service.Methods}}
+
+// {{exportName .}} is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user {{exportName .}}
+func {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{wasmParamType $p}}{{end}}) {{wasmReturnType .}}
+{{- end}}
+
+{{- /* Generate response types for methods that need them */ -}}
+{{range .Service.Methods}}
+{{- if needsRespType .}}
+
+// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
+type {{responseType .}} struct {
+{{- range .Returns}}
+ {{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"`
+{{- end}}
+ Error string `json:"error,omitempty"`
+}
+{{- end}}
+{{- end}}
+
+{{- /* Generate wrapper functions */ -}}
+{{range .Service.Methods}}
+
+// {{$.Service.Name}}{{.Name}} calls the {{exportName .}} host function.
+{{- if .Doc}}
+{{formatDoc .Doc}}
+{{- end}}
+func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{wrapperReturnType . $.Service.Name}} {
+{{- if needsRespType .}}
+{{- /* Complex response - use JSON */}}
+{{- range .Params}}
+{{- if isString .Type}}
+ {{.Name}}Mem := pdk.AllocateString({{.Name}})
+ defer {{.Name}}Mem.Free()
+{{- else if isBytes .Type}}
+ {{.Name}}Mem := pdk.AllocateBytes({{.Name}})
+ defer {{.Name}}Mem.Free()
+{{- else if needsJSON .Type}}
+ {{.Name}}Bytes, err := json.Marshal({{.Name}})
+ if err != nil {
+ return nil, err
+ }
+ {{.Name}}Mem := pdk.AllocateBytes({{.Name}}Bytes)
+ defer {{.Name}}Mem.Free()
+{{- end}}
+{{- end}}
+
+ // Call the host function
+ responsePtr := {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{clientCallArg $p}}{{end}})
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response {{responseType .}}
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+{{- else if isErrorOnly .}}
+{{- /* Error-only response - string result */}}
+{{- range .Params}}
+{{- if isString .Type}}
+ {{.Name}}Mem := pdk.AllocateString({{.Name}})
+ defer {{.Name}}Mem.Free()
+{{- else if isBytes .Type}}
+ {{.Name}}Mem := pdk.AllocateBytes({{.Name}})
+ defer {{.Name}}Mem.Free()
+{{- else if needsJSON .Type}}
+ {{.Name}}Bytes, err := json.Marshal({{.Name}})
+ if err != nil {
+ return err
+ }
+ {{.Name}}Mem := pdk.AllocateBytes({{.Name}}Bytes)
+ defer {{.Name}}Mem.Free()
+{{- end}}
+{{- end}}
+
+ // Call the host function
+ responsePtr := {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{clientCallArg $p}}{{end}})
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ errStr := string(responseMem.ReadBytes())
+
+ if errStr != "" {
+ return errors.New(errStr)
+ }
+
+ return nil
+{{- else}}
+{{- /* Simple return types - direct stack values */}}
+{{- range .Params}}
+{{- if isString .Type}}
+ {{.Name}}Mem := pdk.AllocateString({{.Name}})
+ defer {{.Name}}Mem.Free()
+{{- else if isBytes .Type}}
+ {{.Name}}Mem := pdk.AllocateBytes({{.Name}})
+ defer {{.Name}}Mem.Free()
+{{- end}}
+{{- end}}
+
+ // Call the host function
+{{- if .HasReturns}}
+ result := {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{clientCallArg $p}}{{end}})
+ return {{decodeResult (index .Returns 0) "result"}}
+{{- else}}
+ {{exportName .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{clientCallArg $p}}{{end}})
+{{- end}}
+{{- end}}
+}
+{{- end}}
diff --git a/plugins/cmd/hostgen/internal/templates/host.go.tmpl b/plugins/cmd/hostgen/internal/templates/host.go.tmpl
new file mode 100644
index 000000000..5e952c629
--- /dev/null
+++ b/plugins/cmd/hostgen/internal/templates/host.go.tmpl
@@ -0,0 +1,161 @@
+// Code generated by hostgen. DO NOT EDIT.
+
+package {{.Package}}
+
+import (
+ "context"
+{{- if .NeedsJSON}}
+ "encoding/json"
+{{- end}}
+
+ extism "github.com/extism/go-sdk"
+)
+
+{{- /* Generate request/response types only when needed */ -}}
+{{range .Service.Methods}}
+{{- if needsRequestType .}}
+
+// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}.
+type {{requestType .}} struct {
+{{- range .Params}}
+ {{title .Name}} {{.Type}} `json:"{{.JSONName}}"`
+{{- end}}
+}
+{{- end}}
+{{- if needsRespType .}}
+
+// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
+type {{responseType .}} struct {
+{{- range .Returns}}
+ {{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"`
+{{- end}}
+ Error string `json:"error,omitempty"`
+}
+{{- end}}
+{{end}}
+
+// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
+// The returned host functions should be added to the plugin's configuration.
+func Register{{.Service.Name}}HostFunctions(service {{.Service.Interface}}) []extism.HostFunction {
+ return []extism.HostFunction{
+{{- range .Service.Methods}}
+ new{{$.Service.Name}}{{.Name}}HostFunction(service),
+{{- end}}
+ }
+}
+{{range .Service.Methods}}
+
+func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) extism.HostFunction {
+ return extism.NewHostFunctionWithStack(
+ "{{exportName .}}",
+ func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
+{{- if .HasParams}}
+{{- if needsRequestType .}}
+ // Read JSON request from plugin memory
+ reqBytes, err := p.ReadBytes(stack[0])
+ if err != nil {
+ {{$.Service.Name | lower}}WriteError(p, stack, err)
+ return
+ }
+ var req {{requestType .}}
+ if err := json.Unmarshal(reqBytes, &req); err != nil {
+ {{$.Service.Name | lower}}WriteError(p, stack, err)
+ return
+ }
+{{- else}}
+ // Read parameters from stack
+{{- range $i, $p := .Params}}
+ {{readParam $p $i}}
+{{- end}}
+{{- end}}
+{{- end}}
+
+ // Call the service method
+{{- $m := .}}
+{{- if .HasReturns}}
+{{- if .HasError}}
+ {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, err := service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
+{{- else}}
+ {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
+{{- end}}
+{{- else if .HasError}}
+ err {{if hasErrFromRead .}}={{else}}:={{end}} service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
+{{- else}}
+ service.{{.Name}}(ctx{{range .Params}}, {{if needsRequestType $m}}req.{{title .Name}}{{else}}{{.Name}}{{end}}{{end}})
+{{- end}}
+{{- if .HasError}}
+ if err != nil {
+{{- if isErrorOnly .}}
+ // Write error string to plugin memory
+ if ptr, err := p.WriteString(err.Error()); err == nil {
+ stack[0] = ptr
+ }
+{{- else if needsRespType .}}
+ {{$.Service.Name | lower}}WriteError(p, stack, err)
+{{- end}}
+ return
+ }
+{{- end}}
+
+{{- if isErrorOnly .}}
+ // Write empty string to indicate success
+ if ptr, err := p.WriteString(""); err == nil {
+ stack[0] = ptr
+ }
+{{- else if needsRespType .}}
+ // Write JSON response to plugin memory
+ resp := {{responseType .}}{
+{{- range .Returns}}
+ {{title .Name}}: {{lower .Name}},
+{{- end}}
+ }
+ {{$.Service.Name | lower}}WriteResponse(p, stack, resp)
+{{- else if .HasReturns}}
+ // Write return values to stack
+{{- range $i, $r := .Returns}}
+ {{writeReturn $r $i (lower $r.Name)}}
+{{- end}}
+{{- end}}
+ },
+{{- if needsRequestType $m}}
+ []extism.ValueType{extism.ValueTypePTR},
+{{- else}}
+ []extism.ValueType{ {{- range $i, $p := .Params}}{{if $i}}, {{end}}{{valueType $p.Type}}{{end}}{{if not .HasParams}}{{end}} },
+{{- end}}
+{{- if or (needsRespType .) (isErrorOnly .)}}
+ []extism.ValueType{extism.ValueTypePTR},
+{{- else}}
+ []extism.ValueType{ {{- range $i, $r := .Returns}}{{if $i}}, {{end}}{{valueType $r.Type}}{{end}}{{if not .HasReturns}}{{end}} },
+{{- end}}
+ )
+}
+{{end}}
+{{- if .NeedsWriteHelper}}
+
+// {{.Service.Name | lower}}WriteResponse writes a JSON response to plugin memory.
+func {{.Service.Name | lower}}WriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
+ respBytes, err := json.Marshal(resp)
+ if err != nil {
+ {{.Service.Name | lower}}WriteError(p, stack, err)
+ return
+ }
+ respPtr, err := p.WriteBytes(respBytes)
+ if err != nil {
+ stack[0] = 0
+ return
+ }
+ stack[0] = respPtr
+}
+{{- end}}
+{{- if .NeedsErrorHelper}}
+
+// {{.Service.Name | lower}}WriteError writes an error response to plugin memory.
+func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
+ errResp := struct {
+ Error string `json:"error"`
+ }{Error: err.Error()}
+ respBytes, _ := json.Marshal(errResp)
+ respPtr, _ := p.WriteBytes(respBytes)
+ stack[0] = respPtr
+}
+{{- end}}
diff --git a/plugins/cmd/hostgen/main.go b/plugins/cmd/hostgen/main.go
index 80dec3005..4b3d600cf 100644
--- a/plugins/cmd/hostgen/main.go
+++ b/plugins/cmd/hostgen/main.go
@@ -6,11 +6,13 @@
//
// Flags:
//
-// -input Input directory containing Go source files with annotated interfaces
-// -output Output directory for generated files (default: same as input)
-// -package Output package name (default: inferred from output directory)
-// -v Verbose output
-// -dry-run Preview generated code without writing files
+// -input Input directory containing Go source files with annotated interfaces
+// -output Output directory for generated files (default: same as input)
+// -package Output package name (default: inferred from output directory)
+// -host-only Generate only host-side code (default: false)
+// -plugin-only Generate only plugin/client-side code (default: false)
+// -v Verbose output
+// -dry-run Preview generated code without writing files
package main
import (
@@ -19,20 +21,29 @@ import (
"go/format"
"os"
"path/filepath"
+ "strings"
"github.com/navidrome/navidrome/plugins/cmd/hostgen/internal"
)
func main() {
var (
- inputDir = flag.String("input", ".", "Input directory containing Go source files")
- outputDir = flag.String("output", "", "Output directory for generated files (default: same as input)")
- pkgName = flag.String("package", "", "Output package name (default: inferred from output directory)")
- verbose = flag.Bool("v", false, "Verbose output")
- dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
+ inputDir = flag.String("input", ".", "Input directory containing Go source files")
+ outputDir = flag.String("output", "", "Output directory for generated files (default: same as input)")
+ pkgName = flag.String("package", "", "Output package name (default: inferred from output directory)")
+ hostOnly = flag.Bool("host-only", false, "Generate only host-side code")
+ pluginOnly = flag.Bool("plugin-only", false, "Generate only plugin/client-side code")
+ verbose = flag.Bool("v", false, "Verbose output")
+ dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
)
flag.Parse()
+ // Validate conflicting flags
+ if *hostOnly && *pluginOnly {
+ fmt.Fprintf(os.Stderr, "Error: -host-only and -plugin-only cannot be used together\n")
+ os.Exit(1)
+ }
+
if *outputDir == "" {
*outputDir = *inputDir
}
@@ -54,10 +65,16 @@ func main() {
*pkgName = filepath.Base(absOutput)
}
+ // Determine what to generate
+ generateHost := !*pluginOnly
+ generateClient := !*hostOnly
+
if *verbose {
fmt.Printf("Input directory: %s\n", absInput)
fmt.Printf("Output directory: %s\n", absOutput)
fmt.Printf("Package name: %s\n", *pkgName)
+ fmt.Printf("Generate host code: %v\n", generateHost)
+ fmt.Printf("Generate client code: %v\n", generateClient)
}
// Parse source files
@@ -83,34 +100,85 @@ func main() {
// Generate code for each service
for _, svc := range services {
- code, err := internal.GenerateService(svc, *pkgName)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error generating code for %s: %v\n", svc.Name, err)
- os.Exit(1)
+ // Generate host-side code
+ if generateHost {
+ if err := generateHostCode(svc, *pkgName, absOutput, *dryRun, *verbose); err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating host code for %s: %v\n", svc.Name, err)
+ os.Exit(1)
+ }
}
- // Format the generated code
- formatted, err := format.Source(code)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error formatting generated code for %s: %v\n", svc.Name, err)
- fmt.Fprintf(os.Stderr, "Raw code:\n%s\n", code)
- os.Exit(1)
- }
-
- outputFile := filepath.Join(absOutput, svc.OutputFileName())
-
- if *dryRun {
- fmt.Printf("=== %s ===\n%s\n", outputFile, formatted)
- continue
- }
-
- if err := os.WriteFile(outputFile, formatted, 0600); err != nil {
- fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", outputFile, err)
- os.Exit(1)
- }
-
- if *verbose {
- fmt.Printf("Generated %s\n", outputFile)
+ // Generate client-side code
+ if generateClient {
+ if err := generateClientCode(svc, absOutput, *dryRun, *verbose); err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating client code for %s: %v\n", svc.Name, err)
+ os.Exit(1)
+ }
}
}
}
+
+// generateHostCode generates host-side code for a service.
+func generateHostCode(svc internal.Service, pkgName, outputDir string, dryRun, verbose bool) error {
+ code, err := internal.GenerateHost(svc, pkgName)
+ if err != nil {
+ return fmt.Errorf("generating code: %w", err)
+ }
+
+ formatted, err := format.Source(code)
+ if err != nil {
+ return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code)
+ }
+
+ outputFile := filepath.Join(outputDir, svc.OutputFileName())
+
+ if dryRun {
+ fmt.Printf("=== %s ===\n%s\n", outputFile, formatted)
+ return nil
+ }
+
+ if err := os.WriteFile(outputFile, formatted, 0600); err != nil {
+ return fmt.Errorf("writing file: %w", err)
+ }
+
+ if verbose {
+ fmt.Printf("Generated host code: %s\n", outputFile)
+ }
+ return nil
+}
+
+// generateClientCode generates client-side code for a service.
+func generateClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
+ code, err := internal.GenerateClientGo(svc)
+ if err != nil {
+ return fmt.Errorf("generating code: %w", err)
+ }
+
+ formatted, err := format.Source(code)
+ if err != nil {
+ return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code)
+ }
+
+ // Client code goes in go/ subdirectory
+ clientDir := filepath.Join(outputDir, "go")
+ clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".go")
+
+ if dryRun {
+ fmt.Printf("=== %s ===\n%s\n", clientFile, formatted)
+ return nil
+ }
+
+ // Create go/ subdirectory if needed
+ if err := os.MkdirAll(clientDir, 0755); err != nil {
+ return fmt.Errorf("creating client directory: %w", err)
+ }
+
+ if err := os.WriteFile(clientFile, formatted, 0600); err != nil {
+ return fmt.Errorf("writing file: %w", err)
+ }
+
+ if verbose {
+ fmt.Printf("Generated client code: %s\n", clientFile)
+ }
+ return nil
+}
diff --git a/plugins/cmd/hostgen/testdata/codec_client_expected.go b/plugins/cmd/hostgen/testdata/codec_client_expected.go
new file mode 100644
index 000000000..796a1acdd
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/codec_client_expected.go
@@ -0,0 +1,49 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Codec host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// codec_encode is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user codec_encode
+func codec_encode(uint64) uint64
+
+// CodecEncodeResponse is the response type for Codec.Encode.
+type CodecEncodeResponse struct {
+ Result []byte `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// CodecEncode calls the codec_encode host function.
+func CodecEncode(data []byte) (*CodecEncodeResponse, error) {
+ dataMem := pdk.AllocateBytes(data)
+ defer dataMem.Free()
+
+ // Call the host function
+ responsePtr := codec_encode(dataMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response CodecEncodeResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
diff --git a/plugins/cmd/hostgen/testdata/counter_client_expected.go b/plugins/cmd/hostgen/testdata/counter_client_expected.go
new file mode 100644
index 000000000..8aa15f560
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/counter_client_expected.go
@@ -0,0 +1,25 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Counter host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "github.com/extism/go-pdk"
+)
+
+// counter_count is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user counter_count
+func counter_count(uint64) int32
+
+// CounterCount calls the counter_count host function.
+func CounterCount(name string) int32 {
+ nameMem := pdk.AllocateString(name)
+ defer nameMem.Free()
+
+ // Call the host function
+ result := counter_count(nameMem.Offset())
+ return int32(result)
+}
diff --git a/plugins/cmd/hostgen/testdata/echo_client_expected.go b/plugins/cmd/hostgen/testdata/echo_client_expected.go
new file mode 100644
index 000000000..1ba45b4b4
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/echo_client_expected.go
@@ -0,0 +1,49 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Echo host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// echo_echo is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user echo_echo
+func echo_echo(uint64) uint64
+
+// EchoEchoResponse is the response type for Echo.Echo.
+type EchoEchoResponse struct {
+ Reply string `json:"reply,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// EchoEcho calls the echo_echo host function.
+func EchoEcho(message string) (*EchoEchoResponse, error) {
+ messageMem := pdk.AllocateString(message)
+ defer messageMem.Free()
+
+ // Call the host function
+ responsePtr := echo_echo(messageMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response EchoEchoResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
diff --git a/plugins/cmd/hostgen/testdata/list_client_expected.go b/plugins/cmd/hostgen/testdata/list_client_expected.go
new file mode 100644
index 000000000..9dd7d4ad0
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/list_client_expected.go
@@ -0,0 +1,55 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the List host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// list_items is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user list_items
+func list_items(uint64, uint64) uint64
+
+// ListItemsResponse is the response type for List.Items.
+type ListItemsResponse struct {
+ Count int32 `json:"count,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// ListItems calls the list_items host function.
+func ListItems(name string, filter Filter) (*ListItemsResponse, error) {
+ nameMem := pdk.AllocateString(name)
+ defer nameMem.Free()
+ filterBytes, err := json.Marshal(filter)
+ if err != nil {
+ return nil, err
+ }
+ filterMem := pdk.AllocateBytes(filterBytes)
+ defer filterMem.Free()
+
+ // Call the host function
+ responsePtr := list_items(nameMem.Offset(), filterMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response ListItemsResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
diff --git a/plugins/cmd/hostgen/testdata/math_client_expected.go b/plugins/cmd/hostgen/testdata/math_client_expected.go
new file mode 100644
index 000000000..68ae5a560
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/math_client_expected.go
@@ -0,0 +1,47 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Math host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// math_add is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user math_add
+func math_add(int32, int32) uint64
+
+// MathAddResponse is the response type for Math.Add.
+type MathAddResponse struct {
+ Result int32 `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// MathAdd calls the math_add host function.
+func MathAdd(a int32, b int32) (*MathAddResponse, error) {
+
+ // Call the host function
+ responsePtr := math_add(a, b)
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response MathAddResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
diff --git a/plugins/cmd/hostgen/testdata/meta_client_expected.go b/plugins/cmd/hostgen/testdata/meta_client_expected.go
new file mode 100644
index 000000000..077ce80d9
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/meta_client_expected.go
@@ -0,0 +1,77 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Meta host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// meta_get is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user meta_get
+func meta_get(uint64) uint64
+
+// meta_set is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user meta_set
+func meta_set(uint64) uint64
+
+// MetaGetResponse is the response type for Meta.Get.
+type MetaGetResponse struct {
+ Value any `json:"value,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// MetaGet calls the meta_get host function.
+func MetaGet(key string) (*MetaGetResponse, error) {
+ keyMem := pdk.AllocateString(key)
+ defer keyMem.Free()
+
+ // Call the host function
+ responsePtr := meta_get(keyMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response MetaGetResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
+
+// MetaSet calls the meta_set host function.
+func MetaSet(data map[string]any) error {
+ dataBytes, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+ dataMem := pdk.AllocateBytes(dataBytes)
+ defer dataMem.Free()
+
+ // Call the host function
+ responsePtr := meta_set(dataMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ errStr := string(responseMem.ReadBytes())
+
+ if errStr != "" {
+ return errors.New(errStr)
+ }
+
+ return nil
+}
diff --git a/plugins/cmd/hostgen/testdata/ping_client_expected.go b/plugins/cmd/hostgen/testdata/ping_client_expected.go
new file mode 100644
index 000000000..1215827e8
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/ping_client_expected.go
@@ -0,0 +1,34 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Ping host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// ping_ping is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user ping_ping
+func ping_ping() uint64
+
+// PingPing calls the ping_ping host function.
+func PingPing() error {
+
+ // Call the host function
+ responsePtr := ping_ping()
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ errStr := string(responseMem.ReadBytes())
+
+ if errStr != "" {
+ return errors.New(errStr)
+ }
+
+ return nil
+}
diff --git a/plugins/cmd/hostgen/testdata/search_client_expected.go b/plugins/cmd/hostgen/testdata/search_client_expected.go
new file mode 100644
index 000000000..7f2db4130
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/search_client_expected.go
@@ -0,0 +1,50 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Search host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// search_find is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user search_find
+func search_find(uint64) uint64
+
+// SearchFindResponse is the response type for Search.Find.
+type SearchFindResponse struct {
+ Results []Result `json:"results,omitempty"`
+ Total int32 `json:"total,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// SearchFind calls the search_find host function.
+func SearchFind(query string) (*SearchFindResponse, error) {
+ queryMem := pdk.AllocateString(query)
+ defer queryMem.Free()
+
+ // Call the host function
+ responsePtr := search_find(queryMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response SearchFindResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
diff --git a/plugins/cmd/hostgen/testdata/store_client_expected.go b/plugins/cmd/hostgen/testdata/store_client_expected.go
new file mode 100644
index 000000000..31e392585
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/store_client_expected.go
@@ -0,0 +1,53 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Store host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// store_save is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user store_save
+func store_save(uint64) uint64
+
+// StoreSaveResponse is the response type for Store.Save.
+type StoreSaveResponse struct {
+ Id string `json:"id,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// StoreSave calls the store_save host function.
+func StoreSave(item Item) (*StoreSaveResponse, error) {
+ itemBytes, err := json.Marshal(item)
+ if err != nil {
+ return nil, err
+ }
+ itemMem := pdk.AllocateBytes(itemBytes)
+ defer itemMem.Free()
+
+ // Call the host function
+ responsePtr := store_save(itemMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response StoreSaveResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
diff --git a/plugins/cmd/hostgen/testdata/users_client_expected.go b/plugins/cmd/hostgen/testdata/users_client_expected.go
new file mode 100644
index 000000000..768cee508
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/users_client_expected.go
@@ -0,0 +1,59 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the Users host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// users_get is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user users_get
+func users_get(uint64, uint64) uint64
+
+// UsersGetResponse is the response type for Users.Get.
+type UsersGetResponse struct {
+ Result *User `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// UsersGet calls the users_get host function.
+func UsersGet(id *string, filter *User) (*UsersGetResponse, error) {
+ idBytes, err := json.Marshal(id)
+ if err != nil {
+ return nil, err
+ }
+ idMem := pdk.AllocateBytes(idBytes)
+ defer idMem.Free()
+ filterBytes, err := json.Marshal(filter)
+ if err != nil {
+ return nil, err
+ }
+ filterMem := pdk.AllocateBytes(filterBytes)
+ defer filterMem.Free()
+
+ // Call the host function
+ responsePtr := users_get(idMem.Offset(), filterMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response UsersGetResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
diff --git a/plugins/host/go/nd_host_subsonicapi.go b/plugins/host/go/nd_host_subsonicapi.go
new file mode 100644
index 000000000..092b4d4cd
--- /dev/null
+++ b/plugins/host/go/nd_host_subsonicapi.go
@@ -0,0 +1,53 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the SubsonicAPI host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
+package main
+
+import (
+ "encoding/json"
+ "errors"
+
+ "github.com/extism/go-pdk"
+)
+
+// subsonicapi_call is the host function provided by Navidrome.
+//
+//go:wasmimport extism:host/user subsonicapi_call
+func subsonicapi_call(uint64) uint64
+
+// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
+type SubsonicAPICallResponse struct {
+ ResponseJSON string `json:"responseJSON,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// SubsonicAPICall calls the subsonicapi_call host function.
+// Call executes a Subsonic API request and returns the JSON response.
+//
+// The uri parameter should be the Subsonic API path without the server prefix,
+// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
+func SubsonicAPICall(uri string) (*SubsonicAPICallResponse, error) {
+ uriMem := pdk.AllocateString(uri)
+ defer uriMem.Free()
+
+ // Call the host function
+ responsePtr := subsonicapi_call(uriMem.Offset())
+
+ // Read the response from memory
+ responseMem := pdk.FindMemory(responsePtr)
+ responseBytes := responseMem.ReadBytes()
+
+ // Parse the response
+ var response SubsonicAPICallResponse
+ if err := json.Unmarshal(responseBytes, &response); err != nil {
+ return nil, err
+ }
+
+ if response.Error != "" {
+ return nil, errors.New(response.Error)
+ }
+
+ return &response, nil
+}
diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile
index 9c7baaa1b..ee14054fb 100644
--- a/plugins/testdata/Makefile
+++ b/plugins/testdata/Makefile
@@ -11,7 +11,7 @@ all: $(PLUGINS:%=%.wasm)
clean:
rm -f $(PLUGINS:%=%.wasm)
-%.wasm: %/main.go %/go.mod
+%.wasm: %/*.go %/go.mod
ifdef TINYGO
cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ .
else
diff --git a/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go b/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go
index f3f8cbcef..092b4d4cd 100644
--- a/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go
+++ b/plugins/testdata/fake-subsonicapi-plugin/nd_host_subsonicapi.go
@@ -1,3 +1,8 @@
+// Code generated by hostgen. DO NOT EDIT.
+//
+// This file contains client wrappers for the SubsonicAPI host service.
+// It is intended for use in Navidrome plugins built with TinyGo.
+
package main
import (
@@ -7,26 +12,28 @@ import (
"github.com/extism/go-pdk"
)
-// subsonicapiCall is the host function provided by Navidrome to call the Subsonic API.
-// It takes a URI string and returns a JSON response.
+// subsonicapi_call is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user subsonicapi_call
-func subsonicapiCall(uri uint64) uint64
+func subsonicapi_call(uint64) uint64
-// SubsonicAPICallResponse matches the host response format.
+// SubsonicAPICallResponse is the response type for SubsonicAPI.Call.
type SubsonicAPICallResponse struct {
ResponseJSON string `json:"responseJSON,omitempty"`
Error string `json:"error,omitempty"`
}
-// SubsonicAPICall is a wrapper around the host subsonicapi_call function.
+// SubsonicAPICall calls the subsonicapi_call host function.
+// Call executes a Subsonic API request and returns the JSON response.
+//
+// The uri parameter should be the Subsonic API path without the server prefix,
+// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
func SubsonicAPICall(uri string) (*SubsonicAPICallResponse, error) {
- // Allocate memory for the URI string
- mem := pdk.AllocateString(uri)
- defer mem.Free()
+ uriMem := pdk.AllocateString(uri)
+ defer uriMem.Free()
// Call the host function
- responsePtr := subsonicapiCall(mem.Offset())
+ responsePtr := subsonicapi_call(uriMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)