Gateway plugins extend the core features of an API gateway to handle specific business requirements. This guide covers the full development workflow: writing a Go-based WebAssembly (Wasm) plugin, compiling it, and testing it locally with Docker.
Higress has migrated from TinyGo 0.29 and Go 1.20 to the native Wasm compilation supported by Go 1.24. If you previously compiled plugins with TinyGo, see the Migrate from TinyGo section at the end of this guide.
How it works
A gateway plugin hooks into the HTTP processing lifecycle of the Envoy-based gateway. Four phases are available, each mapped to a mount method in the plugin SDK:
Request flow Response flow
Client ──────────────────────────► Backend ──────────────────────────► Client
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Request headers │ │ Request body │ │ Response headers │ │ Response body │
│ ProcessRequest │ │ ProcessRequest │ │ ProcessResponse │ │ ProcessResponse │
│ HeadersBy │ │ BodyBy │ │ HeadersBy │ │ BodyBy │
└──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘| Phase | Trigger | Mount method |
|---|---|---|
| Request headers | Gateway receives request headers from the client | wrapper.ProcessRequestHeadersBy |
| Request body | Gateway receives the request body from the client | wrapper.ProcessRequestBodyBy |
| Response headers | Gateway receives response headers from the backend service | wrapper.ProcessResponseHeadersBy |
| Response body | Gateway receives the response body from the backend service | wrapper.ProcessResponseBodyBy |
The plugin configuration in the gateway console uses YAML format. It is automatically converted to JSON before being passed to the plugin, so the parseConfig function parses directly from JSON.
Prerequisites
Install Go 1.24 or later. For more information, see the official installation guide.
Plugins compiled with Go 1.24 require MSE Gateway version 2.0.11 or later. For earlier gateway versions, see Develop Wasm plugins using Go (legacy).
Windows
Download the installation file.
Double-click the downloaded file to start the installation. By default, Go is installed in the
Program FilesorProgram Files (x86)folder.After the installation is complete, press Win+R to open the Run dialog. Enter
cmdand click OK to open the command prompt. Rungo versionto verify the installation.
macOS
Download the installation file.
Double-click the downloaded file to start the installation. By default, Go is installed in the
/usr/local/godirectory.Open the terminal and run
go versionto verify the installation.
Linux
Download the installation file.
Run the following commands to install Go:
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gz 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 |
|---|---|
GetHttpRequestHeaders | Get all request headers |
ReplaceHttpRequestHeaders | Replace all request headers |
GetHttpRequestHeader | Get a specific request header |
RemoveHttpRequestHeader | Remove a specific request header |
ReplaceHttpRequestHeader | Replace a specific request header |
AddHttpRequestHeader | Add a request header |
Request body processing (effective during the request body phase)
| Method | Purpose |
|---|---|
GetHttpRequestBody | Get the request body |
AppendHttpRequestBody | Append data to the end of the request body |
PrependHttpRequestBody | Prepend data to the beginning of the request body |
ReplaceHttpRequestBody | Replace the entire request body |
Response header processing (effective during the response header phase)
| Method | Purpose |
|---|---|
GetHttpResponseHeaders | Get all response headers from the backend |
ReplaceHttpResponseHeaders | Replace all response headers |
GetHttpResponseHeader | Get a specific response header |
RemoveHttpResponseHeader | Remove a specific response header |
ReplaceHttpResponseHeader | Replace a specific response header |
AddHttpResponseHeader | Add a response header |
Response body processing (effective during the response body phase)
| Method | Purpose |
|---|---|
GetHttpResponseBody | Get the response body |
AppendHttpResponseBody | Append data to the end of the response body |
PrependHttpResponseBody | Prepend data to the beginning of the response body |
ReplaceHttpResponseBody | Replace the entire response body |
HTTP calls and flow control
| Method | Purpose |
|---|---|
DispatchHttpCall | Send an HTTP request to an external service |
GetHttpCallResponseHeaders | Get response headers from a DispatchHttpCall request |
GetHttpCallResponseBody | Get the response body from a DispatchHttpCall request |
GetHttpCallResponseTrailers | Get response trailers from a DispatchHttpCall request |
SendHttpResponse | Return an HTTP response directly to the client |
ResumeHttpRequest | Resume a paused request processing flow |
ResumeHttpResponse | 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 |
|---|---|
HeaderContinue | The current filter is done. Pass the request to the next filter. Equivalent to types.ActionContinue. |
HeaderStopIteration | 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 HasRequestBody(). |
HeaderContinueAndEndStream | Pass the header to the next filter with end_stream = false, allowing the current filter to append more body data. |
HeaderStopAllIterationAndBuffer | 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 proxywasm.ResumeHttpRequest(), proxywasm.ResumeHttpResponse(), or proxywasm.SendHttpResponseWithDetail(). |
HeaderStopAllIterationAndWatermark | Same as HeaderStopAllIterationAndBuffer, but triggers connection-level throttling when the buffer exceeds the limit instead of returning an error. Equivalent to types.ActionPause in ABI 0.2.1. |
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
}Migrate from TinyGo
If you previously compiled plugins with TinyGo 0.29 and Go 1.20, make the following changes to migrate to Go 1.24:
Update
go.moddependencies. Replace the TinyGo-compatible SDK versions with the Go 1.24 branches listed in Initialize the project.Move initialization from
main()toinit(). Themain()function must be empty. Place allwrapper.SetCtxcalls in theinit()function.Replace
types.ActionPausewithtypes.HeaderStopAllIterationAndWatermark. If your plugin calls an external service during the header processing phase and returnstypes.ActionPause, change it totypes.HeaderStopAllIterationAndWatermark. See the the external HTTP service example.Replace
go-re2with the standardregexppackage. TinyGo had incomplete support for Go's standardregexplibrary, requiring thego-re2workaround. Go 1.24 supportsregexpnatively.