Build custom Go plug-ins to extend AI gateway capabilities, covering plug-in structure, HTTP processing hooks, and local debugging with Docker Compose.
Higress has migrated from TinyGo 0.29 + Go 1.20 to Go 1.24, which natively supports Wasm compilation.
To migrate from TinyGo to Go 1.24: update dependencies in go.mod and move plug-in initialization from main to init.
When adapting TinyGo-based plug-ins:
1. Replace type.ActionPause with types.HeaderStopAllIterationAndWatermark during header processing. See the "Call an external service in a plug-in" section for an example.
2. Replace the go-re2 library with the standard Go regexp package.
Prerequisites
Install Go 1.24 or later.
Go
Install Go 1.24 or later from the official guide.
Plug-ins compiled with Go 1.24 require AI gateway version 2.1.5 or later. Develop a WASM plug-in by using Go covers earlier gateway versions.
Windows
Download the installation file.
Run the installer. Go installs to
Program FilesorProgram Files (x86)by default.Press Win+R, enter
cmd, and click OK. Rungo versionto verify the installation.
macOS
Download the installation file.
Run the installer. Go installs to
/usr/local/goby default.Run
go versionin a terminal to verify the installation.
Linux
Download the installation file.
Run these commands to install Go.
Install Go.
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gzConfigure the environment variable.
export PATH=$PATH:/usr/local/go/binRun
go versionto verify the installation.
Write the plugin
Initialize the project
Create a project directory and initialize a Go module:
mkdir wasm-demo-go && cd wasm-demo-go go mod init wasm-demo-goDownload the plugin SDK dependencies:
go get github.com/higress-group/proxy-wasm-go-sdk@go-1.24 go get github.com/higress-group/wasm-go@main go get github.com/tidwall/gjsonIf you are in Chinese mainland, set a proxy first:
go env -w GOPROXY=https://proxy.golang.com.cn,direct
Write the main.go file
The following example adds a hello: world request header to every incoming request. When mockEnable is true in the plugin configuration, the plugin returns hello world directly instead of forwarding to the backend.
package main
import (
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
func main() {}
func init() {
wrapper.SetCtx(
// Plugin name
"my-plugin",
// Custom configuration parser
wrapper.ParseConfigBy(parseConfig),
// Hook into the request header processing phase
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
// Custom plugin configuration
type MyConfig struct {
mockEnable bool
}
// Parse the JSON configuration into the config struct.
// The gateway console converts YAML to JSON automatically.
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
config.mockEnable = json.Get("mockEnable").Bool()
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
proxywasm.AddHttpRequestHeader("hello", "world")
if config.mockEnable {
proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
}
return types.HeaderContinue
}Key points:
The
main()function must be empty. Place all initialization logic ininit().wrapper.SetCtxregisters the plugin name, configuration parser, and processing hooks.Return
types.HeaderContinue(equivalent totypes.ActionContinue) to pass the request to the next filter.
SDK reference
Utility methods
The plugin SDK provides the following proxywasm methods for request and response manipulation:
Request header processing (effective during the request header phase)
Method | Purpose |
| Get all request headers |
| Replace all request headers |
| Get a specific request header |
| Remove a specific request header |
| Replace a specific request header |
| Add a request header |
Request body processing (effective during the request body phase)
Method | Purpose |
| Get the request body |
| Append data to the end of the request body |
| Prepend data to the beginning of the request body |
| Replace the entire request body |
Response header processing (effective during the response header phase)
Method | Purpose |
| Get all response headers from the backend |
| Replace all response headers |
| Get a specific response header |
| Remove a specific response header |
| Replace a specific response header |
| Add a response header |
Response body processing (effective during the response body phase)
Method | Purpose |
| Get the response body |
| Append data to the end of the response body |
| Prepend data to the beginning of the response body |
| Replace the entire response body |
HTTP calls and flow control
Method | Purpose |
| Send an HTTP request to an external service |
| Get response headers from a |
| Get the response body from a |
| Get response trailers from a |
| Return an HTTP response directly to the client |
| Resume a paused request processing flow |
| Resume a paused response processing flow |
Do not call ResumeHttpRequest or ResumeHttpResponse when processing is not paused. After SendHttpResponse is called, any paused request or response resumes automatically. Calling ResumeHttpRequest or ResumeHttpResponse again causes undefined behavior.
Header status codes
Each processing hook returns a header status code that controls the request flow. Choose the appropriate status based on whether your plugin needs to pause, buffer, or stream data:
Status | Behavior |
| The current filter is done. Pass the request to the next filter. Equivalent to |
| Hold the header but continue reading body data. Use this to modify request headers during the body processing phase. A body is required -- if no body exists, the request blocks indefinitely. Check with |
| Pass the header to the next filter with |
| Stop all iteration and buffer headers, body, and trailers. If the buffer exceeds the limit, the gateway returns 413 during the request phase or 500 during the response phase. Resume with |
| Same as |
For real-world examples of HeaderStopIteration and HeaderStopAllIterationAndWatermark, see the Higress ai-transformer plugin and ai-quota plugin.
Compile the Wasm file
Compile the plugin into a Wasm binary:
go mod tidy
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm ./This produces a main.wasm file. Use this file for Local debugging or upload it through the cloud-native gateway marketplace to deploy a custom plugin.
To deploy through Higress using the WasmPlugin CustomResourceDefinition (CRD) or the console UI, package the Wasm file into an OCI or Docker image. For details, see Custom plugins.
Local debugging
Prerequisites
Install Docker.
Set up the test environment
Make sure main.wasm exists in your project directory, then create the following two files.
docker-compose.yaml
version: '3.7'
services:
envoy:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.1.5
entrypoint: /usr/local/bin/envoy
# Enable debug-level logging for Wasm. Use info level in production.
command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug
depends_on:
- httpbin
networks:
- wasmtest
ports:
- "10000:10000"
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml
- ./main.wasm:/etc/envoy/main.wasm
httpbin:
image: kennethreitz/httpbin:latest
networks:
- wasmtest
ports:
- "12345:80"
networks:
wasmtest: {}envoy.yaml
admin:
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: listener_0
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
scheme_header_transformation:
scheme_to_overwrite: https
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: httpbin
http_filters:
- name: wasmdemo
typed_config:
"@type": type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
value:
config:
name: wasmdemo
vm_config:
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/main.wasm
configuration:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
{
"mockEnable": false
}
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: httpbin
connect_timeout: 30s
type: LOGICAL_DNS
# Comment out the following line to test on v6 networks
dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: httpbin
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: httpbin
port_value: 80Start the environment:
docker compose upVerify
Test the added header. Send a request through the gateway (port 10000) and confirm that the Hello: world header appears:
curl http://127.0.0.1:10000/getExpected response:
{
"args": {},
"headers": {
"Accept": "*/*",
"Hello": "world",
"Host": "127.0.0.1:10000",
"Original-Host": "127.0.0.1:10000",
"Req-Start-Time": "1681269273896",
"User-Agent": "curl/7.79.1",
"X-Envoy-Expected-Rq-Timeout-Ms": "15000"
},
"origin": "172.18.0.3",
"url": "https://127.0.0.1:10000/get"
}The Hello: world header confirms the plugin is active.
For comparison, a direct request to httpbin (port 12345) does not include this header:
curl http://127.0.0.1:12345/getTest a configuration change. Edit envoy.yaml and set mockEnable to true:
configuration:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
{
"mockEnable": true
}Restart the environment and send the same request:
curl http://127.0.0.1:10000/getExpected response:
hello worldThe mock response confirms that configuration changes take effect correctly.
More examples
Plugin without configuration
For a plugin that requires no configuration, define an empty config struct and omit ParseConfigBy:
package main
import (
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
)
func main() {}
func init() {
wrapper.SetCtx(
"hello-world",
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
type MyConfig struct {}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
proxywasm.SendHttpResponse(200, nil, []byte("hello world"), -1)
return types.HeaderContinue
}Call an external HTTP service
Plugins support HTTP calls to Nacos services, Kubernetes services, and fixed-address or DNS services configured in the gateway console. The standard net/http library is unavailable in the Wasm runtime. Use the SDK's encapsulated HTTP client instead.
The following example parses the service configuration during startup, then calls the service during request processing. It extracts a token from the response headers and injects it into the original request.
package main
import (
"errors"
"net/http"
"strings"
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
func main() {}
func init() {
wrapper.SetCtx(
"http-call",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
type MyConfig struct {
// HTTP client for external calls
client wrapper.HttpClient
// Path to request on the external service
requestPath string
// Response header key to extract and inject into the original request
tokenHeader string
}
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
config.tokenHeader = json.Get("tokenHeader").String()
if config.tokenHeader == "" {
return errors.New("missing tokenHeader in config")
}
config.requestPath = json.Get("requestPath").String()
if config.requestPath == "" {
return errors.New("missing requestPath in config")
}
// Full FQDN with service type suffix.
// Examples: my-svc.dns, my-svc.static,
// service-provider.DEFAULT-GROUP.public.nacos,
// httpbin.my-ns.svc.cluster.local
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// Default port for static IP services
servicePort = 80
}
}
config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: servicePort,
})
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
// Send an async HTTP GET request. Default timeout is 500 ms.
err := config.client.Get(config.requestPath, nil,
// Callback runs when the response arrives
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
if statusCode != http.StatusOK {
log.Errorf("http call failed, status: %d", statusCode)
proxywasm.SendHttpResponse(http.StatusInternalServerError, nil,
[]byte("http call failed"), -1)
return
}
log.Infof("get status: %d, response body: %s", statusCode, responseBody)
// Extract the token from the response and add it to the original request
token := responseHeaders.Get(config.tokenHeader)
if token != "" {
proxywasm.AddHttpRequestHeader(config.tokenHeader, token)
}
// Resume the paused request so it can be forwarded to the backend
proxywasm.ResumeHttpRequest()
})
if err != nil {
// If the service call fails, let the request proceed and log the error
log.Errorf("Error occured while calling http, it seems cannot find the service cluster.")
return types.ActionContinue
} else {
// Pause the request until the async callback completes
return types.HeaderStopAllIterationAndWatermark
}
}Call Redis from a plugin
The following example implements a rate-limiting plugin backed by Redis. It tracks requests per minute (QPM) and returns HTTP 429 when the limit is exceeded.
package main
import (
"strconv"
"time"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
"github.com/tidwall/resp"
"github.com/higress-group/wasm-go/pkg/wrapper"
logs "github.com/higress-group/wasm-go/pkg/log"
)
func main() {}
func init() {
wrapper.SetCtx(
"redis-demo",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
)
}
type RedisCallConfig struct {
client wrapper.RedisClient
qpm int
}
func parseConfig(json gjson.Result, config *RedisCallConfig, log logs.Log) error {
// Full FQDN with service type suffix.
// Examples: my-redis.dns, redis.my-ns.svc.cluster.local
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
servicePort = 80
} else {
servicePort = 6379
}
}
username := json.Get("username").String()
password := json.Get("password").String()
// Timeout in milliseconds
timeout := json.Get("timeout").Int()
if timeout == 0 {
timeout = 1000
}
qpm := json.Get("qpm").Int()
config.qpm = int(qpm)
config.client = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: servicePort,
})
return config.client.Init(username, password, timeout)
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log logs.Log) types.Action {
now := time.Now()
minuteAligned := now.Truncate(time.Minute)
timeStamp := strconv.FormatInt(minuteAligned.Unix(), 10)
// If err != nil, the gateway likely cannot reach the Redis backend.
// Verify that the Redis service has not been deleted.
err := config.client.Incr(timeStamp, func(response resp.Value) {
if response.Error() != nil {
log.Errorf("call redis error: %v", response.Error())
proxywasm.ResumeHttpRequest()
} else {
ctx.SetContext("timeStamp", timeStamp)
ctx.SetContext("callTimeLeft", strconv.Itoa(config.qpm-response.Integer()))
if response.Integer() == 1 {
err := config.client.Expire(timeStamp, 60, func(response resp.Value) {
if response.Error() != nil {
log.Errorf("call redis error: %v", response.Error())
}
proxywasm.ResumeHttpRequest()
})
if err != nil {
log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
proxywasm.ResumeHttpRequest()
}
} else {
if response.Integer() > config.qpm {
proxywasm.SendHttpResponse(429, [][2]string{{"timeStamp", timeStamp}, {"callTimeLeft", "0"}}, []byte("Too many requests\n"), -1)
} else {
proxywasm.ResumeHttpRequest()
}
}
}
})
if err != nil {
// If the Redis call fails, let the request proceed and log the error
log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
return types.HeaderContinue
} else {
// Pause the request until the Redis callback completes
return types.HeaderStopAllIterationAndWatermark
}
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config RedisCallConfig, log logs.Log) types.Action {
if ctx.GetContext("timeStamp") != nil {
proxywasm.AddHttpResponseHeader("timeStamp", ctx.GetContext("timeStamp").(string))
}
if ctx.GetContext("callTimeLeft") != nil {
proxywasm.AddHttpResponseHeader("callTimeLeft", ctx.GetContext("callTimeLeft").(string))
}
return types.HeaderContinue
}