You can develop gateway plugins to extend the core features of AI Gateway and meet complex business needs. This topic describes how to develop gateway plugins using Go and provides guidance for local development and debugging.
Higress has migrated from the TinyGo 0.29 and Go 1.20 compilation solution to the native WASM compilation supported by Go 1.24. Go 1.24 natively supports WASM file compilation.
If you previously used TinyGo to compile plugins and want to migrate to the Go 1.24 compilation mode, you must move the plugin initialization logic from the main function to the init function. You also need to adjust the dependencies in the go.mod file. For a specific example, see the content later in this topic.
For plugins originally implemented with TinyGo, note the following adaptation points:
1. If you call an external service during the header processing phase and return type.ActionPause, you must change it to return types.HeaderStopAllIterationAndWatermark. For a reference implementation, see the example of calling an external service from a plugin later in this topic.
2. If you used the go-re2 library because TinyGo's support for the standard regexp library was incomplete, you must replace it with the official Go regexp package.
Prerequisites
Install Go.
Golang
Official guide (version 1.24 or later).
Plugins compiled with Go 1.24 require AI Gateway v2.1.5 or later. For older gateway versions, see Develop WASM plugins using Go.
Windows
Download the installation file.
Open the downloaded installation file to install Go. By default, Go is installed in the
Program FilesorProgram Files (x86)directory.After the installation is complete, press the Win+R keyboard shortcut to open the Run window. Enter
cmdand click OK to open the command prompt. Run thego versioncommand. If the command returns the installed version number, the installation is successful.
macOS
Download the installation file.
Double-click the downloaded installation file to install Go. By default, Go is installed in the
/usr/local/godirectory.Open the Terminal and run the
go versioncommand. If the command returns the installed version number, the installation is successful.
Linux
Download the installation file.
Run the following 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 the
go versioncommand. If the command returns the installed version number, the installation is successful.
Develop a plugin
Initialize the project directory
Create a project directory. For example:
wasm-demo-go.In the directory, run the following command to initialize the Go project.
go mod init wasm-demo-goIf you are in mainland China, you may need to set a proxy for downloading dependencies.
go env -w GOPROXY=https://proxy.golang.com.cn,directDownload the dependencies for building the plugin.
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/gjson
Develop the main.go file
The following example shows how to return hello world when mockEnable: true is configured for the plugin. If the plugin is not configured or mockEnable: false is set, the plugin adds a hello: world request header to the original request.
The plugin configuration in the gateway console is in YAML format. The configuration is automatically converted to JSON format before it is sent to the plugin. Therefore, the parseConfig function in the example can directly parse the configuration from the JSON data.
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",
// A custom function to parse the plugin configuration
wrapper.ParseConfigBy(parseConfig),
// A custom function to process request headers
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}
// Custom plugin configuration
type MyConfig struct {
mockEnable bool
}
// The YAML configuration file from the console is automatically converted to JSON. You can parse the configuration directly from the json parameter.
func parseConfig(json gjson.Result, config *MyConfig, log logs.Log) error {
// Parse the configuration and update it in the config struct.
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
}HTTP processing mount points
In the example code above, the custom function onHttpRequestHeaders is used for processing HTTP request headers via wrapper.ProcessRequestHeadersBy. In addition, you can set custom handler functions for other stages as follows.
HTTP processing phase | Trigger condition | Mount method |
Request header processing | When the gateway receives request headers from the client |
|
Request body processing | When the gateway receives the request body from the client |
|
Response header processing | When the gateway receives response headers from the backend service |
|
Response body processing | When the gateway receives the response body from the backend service |
|
Methods
The proxywasm.AddHttpRequestHeader and proxywasm.SendHttpResponse methods in the preceding example are two methods provided by the plugin SDK. The main methods are listed in the following table.
Category | Method name | Purpose | HTTP processing phase |
Request header processing | GetHttpRequestHeaders | Get all request headers from the client | Request header processing |
ReplaceHttpRequestHeaders | Replace all request headers from the client | Request header processing | |
GetHttpRequestHeader | Get a specific request header from the client | Request header processing | |
RemoveHttpRequestHeader | Remove a specific request header from the client | Request header processing | |
ReplaceHttpRequestHeader | Replace a specific request header from the client | Request header processing | |
AddHttpRequestHeader | Add a request header to the client request | Request header processing | |
Request body processing | GetHttpRequestBody | Get the client request body | Request body processing |
AppendHttpRequestBody | Append a specified byte string to the end of the client request body | Request body processing | |
PrependHttpRequestBody | Append a specified byte string to the beginning of the client request body | Request body processing | |
ReplaceHttpRequestBody | Replace the client request body | Request body processing | |
Response header processing | GetHttpResponseHeaders | Get all response headers from the backend | Response header processing |
ReplaceHttpResponseHeaders | Replace all response headers from the backend | Response header processing | |
GetHttpResponseHeader | Get a specific response header from the backend | Response header processing | |
RemoveHttpResponseHeader | Remove a specific response header from the backend | Response header processing | |
ReplaceHttpResponseHeader | Replace a specific response header from the backend | Response header processing | |
AddHttpResponseHeader | Add a response header to the backend response | Response header processing | |
Response body processing | GetHttpResponseBody | Get the client request body | Response body processing |
AppendHttpResponseBody | Append a specified byte string to the end of the backend response body | Response body processing | |
PrependHttpResponseBody | Append a specified byte string to the beginning of the backend response body | Response body processing | |
ReplaceHttpResponseBody | Replace the backend response body | Response body processing | |
HTTP call | DispatchHttpCall | Send an HTTP request | - |
GetHttpCallResponseHeaders | Get the response headers of a DispatchHttpCall request | - | |
GetHttpCallResponseBody | Get the response body of a DispatchHttpCall request | - | |
GetHttpCallResponseTrailers | Get the response trailers of a DispatchHttpCall request | - | |
Direct response | SendHttpResponse | Return a specific HTTP response directly | - |
Flow resumption | ResumeHttpRequest | Resume a previously paused request processing flow | - |
ResumeHttpResponse | Resume a previously paused response processing flow | - |
Please do not call ResumeHttpRequest or ResumeHttpResponse when the request or response is not in the Paused state. In particular, note that after SendHttpResponse is called, any paused requests or responses will automatically resume. Calling ResumeHttpRequest or ResumeHttpResponse again in this case may result in undefined behavior.
Compile the WASM file
Compile the wasm file
If you use a custom-initialized directory, run the following commands to compile the wasm file.
go mod tidy
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm ./After the compilation, a file named main.wasm is generated. This file is used in the local debugging example that is described later in this topic. You must upload this file when you use the custom plugin feature in the cloud-native gateway marketplace.
Header status management
Header | Description |
| Indicates that the current filter has finished processing and the request can be passed to the next filter. This corresponds to |
| Indicates that the header cannot yet be passed to the next filter for processing. However, data reading from the connection does not stop, and processing of the body data continues. This allows for the possibility of updating the HTTP request headers during the body data handling phase. If the body data needs to be passed to the next filter, the header will be delivered along with the body data to the next filter at that time. Note Note: When this status is returned, a body is required. If there is no body, the request or response will be blocked. To check if a request body exists, use HasRequestBody(). |
| Indicates that the header can be passed to the next filter, but the next filter receives |
| Stops all iterations. This indicates that the header cannot be passed to the next filter, and the current filter cannot receive body data. It buffers the headers, data, and trailers for the current and subsequent filters. If the buffer size exceeds the buffer limit, a 413 status code is returned for requests, and a 500 status code is returned for responses. You must call |
| This is similar to |
Note: For more information about scenarios that involve types.HeaderStopIteration and HeaderStopAllIterationAndWatermark, see the official Higress ai-transformer plugin and ai-quota plugin.
To configure this plugin in Higress using the WasmPlugin CustomResourceDefinition (CRD) or the console UI, you must package the wasm file into an OCI or Docker image. For more information, see Custom Plugins.
Local debugging
Prerequisites
Use Docker Compose to launch and verify
Go to the project directory (take wasm-demo directory as an example). Make sure that the main.wasm file is generated in this directory.
In the directory, create a file named docker-compose.yaml with the following content:
version: '3.7' services: envoy: image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.1.5 entrypoint: /usr/local/bin/envoy # Note: The log level for wasm is set to debug here. For production deployments, the default is info. 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: {}In the same directory, create a file named envoy.yaml with the following content:
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: 80Run the following command to start Docker Compose.
docker compose up
Verify the feature
Verify the WASM feature
Use curl to access httpbin. The following code show the request headers without passing through the gateway.
curl http://127.0.0.1:12345/get { "args": {}, "headers": { "Accept": "*/*", "Host": "127.0.0.1:12345", "User-Agent": "curl/7.79.1" }, "origin": "172.18.0.1", "url": "http://127.0.0.1:12345/get" }Use curl to access httpbin through the gateway. The following code show the request headers after they are processed by the gateway.
curl http://127.0.0.1:10000/get { "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 plugin feature is active, and the hello: world request header is added.
Verify the plugin configuration change
Modify envoy.yaml. Set
mockEnabletotrue.configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | { "mockEnable": true }Use curl to access httpbin through the gateway. The following code shows the response after it is processed by the gateway.
curl http://127.0.0.1:10000/get hello world
This indicates that the plugin configuration change has taken effect. The mock response feature is enabled, and "hello world" is returned.
More examples
Plugin with no configuration
If a plugin requires no configuration, just define an empty struct.
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
}Request external services in a plugin
Currently, only HTTP calls are supported. You can access services registered in Nacos or Kubernetes (K8s) that have their service source configured in the gateway console, as well as services with a fixed address or DNS source. Please note that you cannot use the HTTP client from the net/http library directly; you must use the HTTP client encapsulated as shown in the example below.
In the example, the service type is determined and the corresponding HTTP client is created during the configuration parsing phase. During the request header processing phase, the configured request path is used to access the corresponding service, the response headers are parsed, and then set back into the original request headers.
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 {
// The client used to initiate HTTP calls
client wrapper.HttpClient
// Request URL
requestPath string
// The key to retrieve the corresponding field from the called service's response headers. This field is then set in the original request's headers.
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")
}
// The full FQDN with the service type, for example, my-svc.dns, my-svc.static, service-provider.DEFAULT-GROUP.public.nacos, or httpbin.my-ns.svc.cluster.local
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// The logical port for static IP services is 80
servicePort = 80
}
}
config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: servicePort,
})
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig, log logs.Log) types.Action {
// Use the client's Get method to initiate an HTTP GET call. The timeout parameter is omitted here, so the default timeout is 500 milliseconds.
err := config.client.Get(config.requestPath, nil,
// The callback function, which is executed when the response is returned asynchronously.
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
// The request did not return a 200 status code. Handle the error.
if statusCode != http.StatusOK {
log.Errorf("http call failed, status: %d", statusCode)
proxywasm.SendHttpResponse(http.StatusInternalServerError, nil,
[]byte("http call failed"), -1)
return
}
// Print the HTTP status code and response body.
log.Infof("get status: %d, response body: %s", statusCode, responseBody)
// Parse the token field from the response headers and set it in the original request headers.
token := responseHeaders.Get(config.tokenHeader)
if token != "" {
proxywasm.AddHttpRequestHeader(config.tokenHeader, token)
}
// Resume the original request flow to continue processing and forward it to the backend service.
proxywasm.ResumeHttpRequest()
})
if err != nil {
// The external service call failed. Continue the request and log the error.
log.Errorf("Error occured while calling http, it seems cannot find the service cluster.")
return types.ActionContinue
} else {
// Wait for the asynchronous callback to complete. Return HeaderStopAllIterationAndWatermark, which can be resumed by ResumeHttpRequest.
return types.HeaderStopAllIterationAndWatermark
}
}Call Redis in a plugin
Use the following code example to implement a Redis rate-limiting plugin.
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 {
// The full FQDN with the service type, for example, my-redis.dns or redis.my-ns.svc.cluster.local
serviceName := json.Get("serviceName").String()
servicePort := json.Get("servicePort").Int()
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// The logical port for static IP services is 80
servicePort = 80
} else {
servicePort = 6379
}
}
username := json.Get("username").String()
password := json.Get("password").String()
// Unit: 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 the Redis API returns err != nil, it is usually because the gateway cannot find the Redis backend service. Check if the Redis backend service was accidentally 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 {
// The Redis call failed. Continue the request and log the error.
log.Errorf("Error occured while calling redis, it seems cannot find the redis cluster.")
return types.HeaderContinue
} else {
// Hold the request and wait for the Redis call to complete.
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
}